diff --git a/.gitignore b/.gitignore index 103eb13..28bbc84 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ Android/**/.cxx/ # Android 로컬 설정 및 보안 키 Android/local.properties Android/**/local.properties +Android/signing.properties Android/**/*.jks Android/**/*.keystore Android/**/*.key @@ -56,6 +57,17 @@ Android/*.ipr Android/.vscode/ Android/**/.vscode/ +# Android 앱 테스트 및 검증 코드 (DCT_test 프로그램은 제외하지 않음) +Android/app/src/test/ +Android/app/src/androidTest/ +Android/launch_test.ps1 +Android/run_tests.ps1 +Android/scripts/verify-local.ps1 +Android/scripts/verify-ui-local.ps1 +Android/scripts/verify_mf4_fixtures.py +Android/scripts/pc_mf4_reference.py +Android/scripts/PcMdf4Reference.cs + # ------------------------------------------------------------------------------ # 3. DCT_test 빌드 산출물 및 캐시 제외 # ------------------------------------------------------------------------------ diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 773f3b1..c0412df 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -1,9 +1,17 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") } +// Machine-local signing credentials; never commit the keystore or passwords. +val releaseSigningProperties = Properties().apply { + val propertiesFile = rootProject.file("signing.properties") + if (propertiesFile.isFile) propertiesFile.inputStream().use { load(it) } +} + android { namespace = "com.dct.app" compileSdk = 34 @@ -13,7 +21,7 @@ android { minSdk = 26 targetSdk = 34 versionCode = 1 - versionName = "1.0.0" + versionName = "1.0.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -21,8 +29,18 @@ android { } } + signingConfigs { + create("release") { + storeFile = releaseSigningProperties.getProperty("storeFile")?.let { rootProject.file(it) } + storePassword = releaseSigningProperties.getProperty("storePassword") + keyAlias = releaseSigningProperties.getProperty("keyAlias") + keyPassword = releaseSigningProperties.getProperty("keyPassword") + } + } + buildTypes { release { + signingConfig = signingConfigs.getByName("release") isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -35,6 +53,17 @@ android { } } + applicationVariants.all { + if (buildType.name == "release") { + val appVersion = versionName + + outputs.all { + (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl) + .outputFileName = "dct_v${appVersion}.apk" + } + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 diff --git a/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt b/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt deleted file mode 100644 index 1195d90..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -package com.dct.app.background - -import android.app.Notification -import android.app.NotificationManager -import android.os.SystemClock -import androidx.activity.ComponentActivity -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.logger.CsvMeasurementLogger -import com.dct.app.data.logger.Mf4MeasurementLogger -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.data.protocol.SlidingRingBufferParser -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.chart.ChartSignalSeries -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.presentation.main.MainUiState -import com.dct.app.presentation.main.MeasurementRecordingState -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.* -import org.junit.Test -import java.io.File -import java.net.InetAddress -import java.net.ServerSocket -import java.net.Socket -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicReference -import kotlin.concurrent.thread - -/** Local protocol fixture only: no simulation code or alternate network path in the app. */ -class BackgroundMeasurementServiceTest { - private val instrumentation = InstrumentationRegistry.getInstrumentation() - private val app = ApplicationProvider.getApplicationContext() - - @Test - fun backgroundKeepsReceivingAckGraphAndCsvThenStopKeepsTransport() = runSession(screenOff = false) - - @Test - fun screenOffKeepsReceivingAckGraphAndCsvThenStopKeepsTransport() = runSession(screenOff = true) - - private fun runSession(screenOff: Boolean) { - val scenario = ActivityScenario.launch(ComponentActivity::class.java) - val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")) - val peer = AtomicReference() - val peerError = AtomicReference() - val acknowledgements = AtomicInteger() - val starts = AtomicInteger() - val stops = AtomicInteger() - val codec = DctPacketCodec() - val directory = File(app.cacheDir, "background-regression-${System.nanoTime()}") - val logger = CsvMeasurementLogger(directory, listOf("sm_01_01" to "C"), "background.csv") - val mf4 = Mf4MeasurementLogger(directory, listOf("sm_01_01" to "C"), "background.mf4") - val session = app.session - lateinit var series: ChartSignalSeries - val peerThread = thread(name = "BackgroundProtocolFixture", isDaemon = true) { - try { - val socket = server.accept() - peer.set(socket) - val parser = SlidingRingBufferParser() - val input = socket.getInputStream() - val bytes = ByteArray(4096) - while (true) { - val count = input.read(bytes) - if (count < 0) break - for (packet in parser.parseStream(bytes, count)) { - when (packet.command.toInt() and 0xFF) { - 0x20 -> { starts.incrementAndGet(); send(socket, codec.encode(DctPacket(0xA0.toByte(), byteArrayOf(1, packet.payload.single())))) } - 0x21 -> { stops.incrementAndGet(); send(socket, codec.encode(DctPacket(0xA1.toByte(), byteArrayOf(1)))) } - 0x24 -> { assertArrayEquals(byteArrayOf(1), packet.payload); acknowledgements.incrementAndGet() } - 0x05 -> send(socket, codec.encode(DctPacket(0x85.toByte(), byteArrayOf(1)))) - } - } - } - } catch (error: Throwable) { - if (!server.isClosed) peerError.set(error) - } - } - try { - instrumentation.runOnMainSync { - session.onUiStarted() - assertTrue(session.ensureForegroundService()) - session.viewModel.chartRenderer.replaceSignalBindings(listOf( - ChartSignalSeries("sm_01_01", "sm_01_01", AxisPosition.LEFT_0, 0x0000FF) - )) - series = session.viewModel.chartRenderer.seriesListFlow.value.single() - session.viewModel.wifiSocketManager.connect("127.0.0.1", server.localPort) - } - await { session.serviceRunning.value && session.viewModel.wifiSocketManager.isConnected.value && peer.get() != null } - // Fixture bypasses equipment setup only, exercising the real START/STOP transactions. - session.viewModel.javaClass.getDeclaredField("configurationRequired").apply { - isAccessible = true; setBoolean(session.viewModel, false) - } - field>(session.viewModel.packetRepository, "_connectionState").value = DeviceConnectionState.READY - instrumentation.runOnMainSync { session.viewModel.startMeasure(30) } - await { session.viewModel.uiState.value.isMeasuring && starts.get() == 1 } - assertNotNull(logger.start()) - assertNotNull(mf4.start()) - field>(session.viewModel, "activeCsvLoggers").add(logger) - field>(session.viewModel, "activeMf4Loggers").add(mf4) - val ui = field>(session.viewModel, "_uiState") - instrumentation.runOnMainSync { - ui.value = ui.value.copy(recordingState = MeasurementRecordingState.RECORDING, - recordingStartedAtMillis = System.currentTimeMillis(), recordingSampleCount = 0, packetCount = 0) - } - await { session.executionState.value.recording } - fun transmit(first: Int, last: Int) { - for (value in first..last) { - val frame = codec.encode(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, value.toByte(), 0, 0))) - // Also check a fragmented frame while using the production parser/writer. - if (value % 7 == 0) { - send(peer.get(), frame.copyOfRange(0, 3)) - send(peer.get(), frame.copyOfRange(3, frame.size)) - } else send(peer.get(), frame) - SystemClock.sleep(33) - } - await { acknowledgements.get() == last && ui.value.recordingSampleCount == last.toLong() } - assertEquals(last.toDouble(), series.buffer.getLatestValue()!!, 0.0001) - } - transmit(1, 10) - val original = session.viewModel - scenario.recreate() - assertSame(original, app.session.viewModel) - scenario.moveToState(Lifecycle.State.CREATED) - instrumentation.runOnMainSync { session.onUiStopped() } - transmit(11, 40) - assertTrue(session.serviceRunning.value) - if (screenOff) shell("input keyevent KEYCODE_SLEEP") - transmit(41, 60) - assertEquals(60, series.buffer.toList().count { it.second.isFinite() }) - val notifications = app.getSystemService(NotificationManager::class.java).activeNotifications - notifications.firstOrNull { it.id == MeasurementForegroundService.NOTIFICATION_ID }?.let { - assertTrue(it.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) - assertEquals("DCT · 계측 및 기록 중", it.notification.extras.getString(Notification.EXTRA_TITLE)) - } // Drawer visibility is user-controlled; do not grant notification permission silently. - assertTrue(heldWakeLocks().contains("DCT:MeasurementSession")) - val notification = MeasurementForegroundService.createNotification(app, session.executionState.value) - assertEquals("DCT · 계측 및 기록 중", notification.extras.getString(Notification.EXTRA_TITLE)) - assertTrue(notification.flags and Notification.FLAG_ONGOING_EVENT != 0) - assertEquals(listOf("계측 정지", "연결 해제"), notification.actions.map { it.title.toString() }) - notification.actions[0].actionIntent.send() - await { !ui.value.isMeasuring && stops.get() == 1 } - assertTrue(session.viewModel.wifiSocketManager.isConnected.value) - assertTrue(session.serviceRunning.value) - // Validate sample rows independently of the multi-row header. - assertEquals(60, logger.outputFile!!.readLines().count { - Regex("^[0-9]+\\.[0-9]+,").containsMatchIn(it) - }) - val mf4Bytes = mf4.outputFile!!.readBytes() - fun block(name: String) = (0..mf4Bytes.size - 4).first { - mf4Bytes.copyOfRange(it, it + 4).contentEquals(name.toByteArray(Charsets.US_ASCII)) - } - val binary = ByteBuffer.wrap(mf4Bytes).order(ByteOrder.LITTLE_ENDIAN) - assertEquals(60L, binary.getLong(block("##CG") + 80)) - val dataBlock = block("##DT") - assertEquals(24L + 60 * 16, binary.getLong(dataBlock + 8)) - assertEquals(60.0, binary.getDouble(dataBlock + 24 + 59 * 16 + 8), 0.0001) - instrumentation.runOnMainSync { session.viewModel.startMeasure(30) } - await { ui.value.isMeasuring && starts.get() == 2 } - notification.actions[1].actionIntent.send() - await { !session.viewModel.wifiSocketManager.isConnected.value && !session.serviceRunning.value } - if (!screenOff) assertFalse(heldWakeLocks().contains("DCT:MeasurementSession")) - // Screen-off may disconnect wireless ADB/UiAutomation. Check cleanup via - // serviceRunning above and via dumpsys after reconnect; the awake test checks OS-held locks. - assertNull(peerError.get()) - } finally { - if (screenOff) shell("input keyevent KEYCODE_WAKEUP") - server.close() - peer.get()?.close() - instrumentation.runOnMainSync { session.onUiStopped(); session.viewModel.disconnectCommunication() } - logger.stop() - mf4.stop() - scenario.close() - peerThread.join(2000) - // Delete only this test's uniquely named cache fixture, never project/measurement data. - directory.deleteRecursively() - } - } - - private fun send(socket: Socket, bytes: ByteArray) = synchronized(socket) { - socket.getOutputStream().apply { write(bytes); flush() } - } - - private fun await(condition: () -> Boolean) { - val deadline = SystemClock.elapsedRealtime() + 15_000 - while (!condition() && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(25) - assertTrue("Timed out waiting for session state", condition()) - } - - @Suppress("UNCHECKED_CAST") - private fun field(owner: Any, name: String): T = owner.javaClass.getDeclaredField(name).run { - isAccessible = true - get(owner) as T - } - - private fun shell(command: String): String = instrumentation.uiAutomation.executeShellCommand(command).use { - android.os.ParcelFileDescriptor.AutoCloseInputStream(it).bufferedReader().readText() - } - - private fun heldWakeLocks(): String { - val dump = shell("dumpsys power") - assertTrue("UiAutomation power dump unavailable", dump.contains("Wake Locks:")) - return dump.substringAfter("Wake Locks:").substringBefore("Suspend Blockers:") - } -} 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 deleted file mode 100644 index e976422..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/background/ProgramExitSessionTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -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/ProjectDialogHoverTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt deleted file mode 100644 index 767bff4..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.dct.app.presentation.dialog - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.toPixelMap -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.captureToImage -import androidx.compose.ui.test.click -import androidx.compose.ui.test.isDialog -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performMouseInput -import androidx.compose.ui.test.performTouchInput -import com.dct.app.presentation.project.NewProjectDialog -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import java.io.File - -/** - * Mouse and pen hover feed the same Compose HoverInteraction state layer. - * Hovering a non-interactive title must not paint the full-screen dismiss target. - * Run on a device/emulator; real stylus input still needs tablet verification. - */ -@OptIn(ExperimentalTestApi::class) -class ProjectDialogHoverTest { - @get:Rule - val compose = createComposeRule() - - @Test - fun newProject_hoverDoesNotDimAndOutsideTapStillDismisses() { - val visible = mutableStateOf(true) - compose.setContent { - MaterialTheme { - if (visible.value) { - NewProjectDialog(onDismiss = { visible.value = false }, onConfirm = {}) - } - } - } - - assertStableHoverAndBodyTap("New project") - compose.onNode(isDialog()).performTouchInput { click(Offset(2f, 2f)) } - compose.onNode(isDialog()).assertDoesNotExist() - } - - @Test - fun openProject_hoverDoesNotDimAndCloseStillWorks() { - val visible = mutableStateOf(true) - compose.setContent { - MaterialTheme { - if (visible.value) { - OpenProjectDialog( - projectFiles = emptyList(), - onSelectFile = {}, - onBrowseOther = {}, - onDismiss = { visible.value = false } - ) - } - } - } - - assertStableHoverAndBodyTap("Open Project file") - compose.onNodeWithContentDescription("Close").performClick() - compose.onNode(isDialog()).assertDoesNotExist() - } - - @Test - fun openProjectFileCanBeSubmittedOnlyOnce() { - var selections = 0 - compose.setContent { - MaterialTheme { - OpenProjectDialog( - projectFiles = listOf(File("workspace.dpc")), - onSelectFile = { selections++ }, - onBrowseOther = {}, - onDismiss = {} - ) - } - } - - compose.onNodeWithText("workspace.dpc").performTouchInput { - click(center) - click(center) - } - compose.runOnIdle { assertEquals(1, selections) } - } - - private fun assertStableHoverAndBodyTap(title: String) { - val dialog = compose.onNode(isDialog()) - val before = dialog.captureToImage().toPixelMap() - val titleNode = compose.onNodeWithText(title, useUnmergedTree = true) - - titleNode.performMouseInput { enter(center) } - // Allow any default hover indication animation to finish before comparing. - compose.mainClock.advanceTimeBy(500) - compose.waitForIdle() - val after = dialog.captureToImage().toPixelMap() - - assertEquals(before.width, after.width) - assertEquals(before.height, after.height) - for (y in 0 until before.height) { - for (x in 0 until before.width) { - assertEquals("Hover changed dialog pixel ($x, $y)", before[x, y], after[x, y]) - } - } - - titleNode.performMouseInput { exit() } - titleNode.performTouchInput { click(center) } - dialog.assertExists() - } -} 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 deleted file mode 100644 index b6bc6bd..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt +++ /dev/null @@ -1,416 +0,0 @@ -package com.dct.app.presentation.dialog - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import com.dct.app.data.communication.* -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import android.Manifest -import android.content.pm.PackageManager -import androidx.test.platform.app.InstrumentationRegistry - -class WifiApConnectionUiTest { - @get:Rule val compose = createComposeRule() - private val device = WifiApDevice("DCT_DEVICE", "", -40, WifiApSecurity.WPA2) - private val state = mutableStateOf(WifiApUiState(enabled = true, permissionGranted = true, - locationEnabled = true, devices = listOf(device))) - private var connected: Pair? = null - private var cancelled = 0 - private var dismissed = false - private var disconnected = 0 - private var current = 0 - private var scanned = 0 - private var settingsAction: String? = null - private val endpoint = mutableStateOf(WifiApEndpoint()) - 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() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals(PackageManager.PERMISSION_GRANTED, - context.checkSelfPermission(Manifest.permission.CHANGE_NETWORK_STATE)) - } - private fun show() { - compose.setContent { MaterialTheme { - WifiApConnectionContent(state.value, communicationReady.value, socketConnected.value, - onScan = { scanned++ }, onConnect = { ap, password -> connected = ap to password }, - onCancel = { cancelled++ }, onDisconnect = { disconnected++ }, - onCurrent = { current++ }, onPermissions = {}, onSettings = { settingsAction = it }, - onDismiss = { dismissed = true }, endpoint = endpoint.value, connectedEndpoint = connectedEndpoint.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() - compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() - compose.onNodeWithTag("wifi:ap-ssid").assertDoesNotExist() - compose.onNodeWithTag("wifi:ap-connect").assertDoesNotExist() - compose.onNodeWithText("Wi-Fi Direct").assertDoesNotExist() - compose.onNodeWithTag("wifi:manual-ip").assertDoesNotExist() - compose.onNodeWithTag("wifi:manual-port").assertDoesNotExist() - compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() - compose.onNodeWithTag("wifi:ap-connect").assertIsNotEnabled() - compose.onNodeWithTag("wifi:ap-password").performTextInput("12345678") - compose.onNodeWithTag("wifi:ap-connect").assertIsEnabled().performClick() - compose.runOnIdle { assertEquals(device to "12345678", connected) } - compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() - } - @Test fun closingTheSheetDoesNotCancelTheApLeaseAndPendingConnectionCanBeCancelled() { - state.value = state.value.copy(phase = WifiApPhase.CONNECTING, selectedSsid = device.ssid) - show() - compose.onNodeWithText("연결 취소").assertIsDisplayed().performClick() - compose.runOnIdle { assertEquals(1, cancelled) } - compose.onNodeWithText("닫기").assertIsDisplayed().performClick() - compose.runOnIdle { assertTrue(dismissed); assertEquals(1, cancelled) } - } - @Test fun permissionDenialDisablesApRequestButLeavesSettingsAvailable() { - state.value = state.value.copy(permissionGranted = false) - show() - compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertIsNotEnabled() - compose.onNodeWithContentDescription("새로고침").assertIsNotEnabled() - compose.onNodeWithText("권한 허용").assertIsDisplayed().assertIsEnabled() - compose.onNodeWithContentDescription("더보기").performClick() - compose.onNodeWithText("네트워크 추가").assertIsNotEnabled() - compose.onNodeWithText("Wi-Fi 설정").assertIsEnabled().performClick() - compose.runOnIdle { assertNull(connected) } - } - - @Test fun cancellingPasswordPromptForgetsCredentialsWithoutConnecting() { - show() - compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() - compose.onNodeWithTag("wifi:ap-password").performTextInput("12345678") - compose.onNodeWithText("취소").performClick() - compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() - assertEquals("", compose.onNodeWithTag("wifi:ap-password").fetchSemanticsNode() - .config[SemanticsProperties.EditableText].text) - compose.runOnIdle { assertNull(connected); assertEquals(0, cancelled) } - } - - @Test fun advancedInputsAndFixedEndpointAreOnlyInMenuAndDetails() { - show() - compose.onNodeWithContentDescription("더보기").performClick() - compose.onNodeWithText("네트워크 추가").performClick() - compose.onNodeWithTag("wifi:ap-ssid").performTextInput("HIDDEN_DCT") - compose.onNodeWithTag("wifi:ap-password").performTextInput("12345678") - compose.onNodeWithTag("wifi:ap-connect").performClick() - compose.runOnIdle { assertEquals("HIDDEN_DCT", connected!!.first.ssid) } - compose.onNodeWithContentDescription("더보기").performClick() - compose.onNodeWithText("연결 정보").performClick() - compose.onNodeWithTag("wifi:ap-endpoint").assertTextContains("192.168.4.1:5000", substring = true) - compose.onNodeWithText("확인").performClick() - compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist() - } - - @Test fun connectedRowShowsInlineDisconnectWithoutOpeningDetails() { - state.value = state.value.copy(phase = WifiApPhase.CONNECTED, - selectedSsid = device.ssid, localAddress = "192.168.4.20") - show() - compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertDoesNotExist() - val disconnect = compose.onNodeWithTag("wifi:ap-current:disconnect").assertIsDisplayed() - val name = compose.onNodeWithText(device.ssid, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertTrue(name.right <= disconnect.fetchSemanticsNode().boundsInRoot.left) - compose.onNodeWithTag("wifi:ap-current").performClick() - compose.onNodeWithText("IP 주소: 192.168.4.20").assertIsDisplayed() - compose.onNodeWithText("통신 재연결").assertDoesNotExist() - compose.runOnIdle { assertEquals(0, disconnected) } - compose.onNodeWithText("확인").performClick() - disconnect.performClick() - compose.onNodeWithText("IP 주소: 192.168.4.20").assertDoesNotExist() - compose.runOnIdle { assertEquals(1, disconnected) } - } - - @Test fun alreadyConnectedSystemWifiIsVisibleBeforeTcpAndIsNotRepeatedInScanResults() { - state.value = state.value.copy(connectedNetworks = listOf( - ConnectedWifiNetwork("101", device.ssid, "192.168.1.20"))) - show() - compose.onNodeWithTag("wifi:connected:101").assertIsDisplayed() - compose.onNodeWithText("Wi-Fi 연결됨").assertIsDisplayed() - compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertDoesNotExist() - compose.onNodeWithTag("wifi:ap-current").assertDoesNotExist() - compose.onNodeWithTag("wifi:connected:101").performClick() - compose.onNodeWithText("IP 주소: 192.168.1.20").assertIsDisplayed() - compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() - compose.onNode(hasText("연결 해제") and hasAnyAncestor(hasTestTag("wifi:connected-details"))) - .assertDoesNotExist() - compose.onNodeWithText("확인").performClick() - compose.onNodeWithTag("wifi:connected:101:disconnect").assertIsDisplayed().performClick() - compose.runOnIdle { assertEquals(android.provider.Settings.ACTION_WIFI_SETTINGS, settingsAction) } - compose.runOnIdle { assertEquals(0, current); assertNull(connected) } - } - - @Test fun managedNetworkIsShownOnlyOnceAndOtherConnectedWifiRemainsVisible() { - state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = device.ssid, - localAddress = "192.168.4.20", activeNetworkKey = "101", connectedNetworks = listOf( - ConnectedWifiNetwork("101", device.ssid, "192.168.4.20"), - ConnectedWifiNetwork("102", "Office", "192.168.1.20"))) - show() - compose.onNodeWithTag("wifi:ap-current").assertIsDisplayed() - compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist() - compose.onNodeWithTag("wifi:connected:102").assertIsDisplayed() - compose.onAllNodesWithText("Wi-Fi 연결됨").assertCountEquals(2) - compose.onNodeWithTag("wifi:ap-current").performClick() - compose.onNodeWithText("통신 재연결").assertDoesNotExist() - } - - @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").assertDoesNotExist() - compose.onNodeWithTag("wifi:connected:101").performClick() - compose.onNode(hasText("접속 설정: 192.168.1.100:6000") and - hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertIsDisplayed() - compose.onAllNodes(hasTestTag("wifi:connected-connect") and - hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertCountEquals(0) - compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() - compose.runOnIdle { assertEquals(0, current); assertNull(connected) } - compose.onNodeWithText("확인").performClick() - compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() - compose.runOnIdle { - assertEquals(0, current); assertEquals(0, disconnected) - assertFalse(dismissed) - assertNull(connected); assertEquals(WifiApEndpoint("192.168.1.100", 6000), endpoint.value) - } - } - - @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").assertDoesNotExist() - listOf(base.copy(permissionGranted = false), base.copy(enabled = false), - base.copy(phase = WifiApPhase.CONNECTING), - base.copy(connectedNetworks = emptyList()), - base.copy(connectedNetworks = listOf(network.copy(localAddress = null))), - base.copy(connectedNetworks = listOf(network, ConnectedWifiNetwork("102", "Other", "192.168.2.20")))) - .forEach { unavailable -> - compose.runOnIdle { state.value = unavailable } - compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() - } - compose.runOnIdle { state.value = base; endpointEditable.value = false } - compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() - compose.runOnIdle { endpointEditable.value = true; communicationReady.value = true } - compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() - compose.runOnIdle { communicationReady.value = false; socketConnected.value = true } - compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() - compose.runOnIdle { socketConnected.value = false; endpoint.value = WifiApEndpoint("0.0.0.0", 6000) } - compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() - compose.runOnIdle { endpoint.value = WifiApEndpoint() } - compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() - compose.runOnIdle { assertEquals(0, current); assertNull(connected) } - } - - @Test fun wifiOnlyRowShowsConfiguredIpAndPortWithoutClaimingTcpConnection() { - endpoint.value = WifiApEndpoint("192.168.1.100", 6000) - state.value = state.value.copy(connectedNetworks = listOf( - ConnectedWifiNetwork("101", "Office", "192.168.1.20"))) - show() - compose.onNodeWithTag("wifi:connected:101:endpoint", useUnmergedTree = true) - .assertIsDisplayed().assertTextEquals("접속 설정: 192.168.1.100:6000") - compose.runOnIdle { endpoint.value = WifiApEndpoint("192.168.4.1", 5000) } - compose.onNodeWithTag("wifi:connected:101:endpoint", useUnmergedTree = true).assertTextEquals("접속 설정: 192.168.4.1:5000") - compose.onNodeWithText("Wi-Fi 연결됨").assertIsDisplayed() - compose.runOnIdle { assertEquals(0, current); assertNull(connected) } - } - - @Test fun connectedRowKeepsTheActualPeerWhenNextConnectionSettingsChange() { - state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = device.ssid, - localAddress = "192.168.1.20", activeNetworkKey = "101") - endpoint.value = WifiApEndpoint("192.168.1.100", 7000) - connectedEndpoint.value = WifiApEndpoint("192.168.1.100", 6000) - socketConnected.value = true - show() - compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true) - .assertIsDisplayed().assertTextEquals("통신 주소: 192.168.1.100:6000") - compose.runOnIdle { - communicationReady.value = true - endpoint.value = WifiApEndpoint("192.168.4.1", 5000) - } - compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true).assertTextEquals("통신 주소: 192.168.1.100:6000") - compose.runOnIdle { connectedEndpoint.value = null } - compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true).assertTextEquals("통신 주소 확인 중…") - compose.runOnIdle { socketConnected.value = false; communicationReady.value = false } - compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true).assertTextEquals("접속 설정: 192.168.4.1:5000") - } - - @Test fun unknownSystemWifiIsDisplayedWithoutScanResultsAndDisappearsWhenLost() { - state.value = state.value.copy(permissionGranted = false, devices = emptyList(), - connectedNetworks = listOf(ConnectedWifiNetwork("101", localAddress = "192.168.1.20"))) - show() - compose.onNodeWithTag("wifi:connected:101").assertIsDisplayed().performClick() - compose.onNodeWithText("IP 주소: 192.168.1.20").assertIsDisplayed() - compose.runOnIdle { state.value = state.value.copy(connectedNetworks = emptyList()) } - compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist() - compose.onNodeWithText("IP 주소: 192.168.1.20").assertDoesNotExist() - compose.runOnIdle { assertEquals(0, current); assertEquals(0, disconnected) } - } - - @Test fun openNetworkHasNoPasswordAndUnsupportedSecurityUsesSettings() { - state.value = state.value.copy(devices = listOf(device.copy(security = WifiApSecurity.OPEN))) - show() - compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() - compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() - compose.onNodeWithTag("wifi:ap-connect").assertIsEnabled().performClick() - compose.runOnIdle { - assertEquals("", connected!!.second) - state.value = state.value.copy(devices = listOf(device.copy(security = WifiApSecurity.UNSUPPORTED))) - } - compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() - compose.onNodeWithTag("wifi:ap-connect").assertDoesNotExist() - compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() - compose.onNodeWithText("Wi-Fi 설정").performClick() - compose.runOnIdle { assertEquals(android.provider.Settings.ACTION_WIFI_SETTINGS, settingsAction) } - } - - @Test fun longNetworkListScrollsWithoutHidingHeaderOrCloseButton() { - state.value = state.value.copy(devices = (1..30).map { device.copy(ssid = "DCT_$it") }, - connectedNetworks = listOf(ConnectedWifiNetwork("101", "Office", "192.168.1.20"))) - show() - compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(31) - compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed() - 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() - compose.onNodeWithContentDescription("더보기").performClick() - compose.onNodeWithText("현재 Wi-Fi로 연결").assertIsEnabled().performClick() - compose.runOnIdle { assertEquals(1, current) } - } - - @Test fun tcpSettingsValidateAndSavePcAddressWithoutConnectingThenRestoreDefault() { - 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").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) - } - compose.onNodeWithContentDescription("더보기").performClick() - compose.onNodeWithText("연결 정보").performClick() - compose.onNodeWithTag("wifi:ap-endpoint").assertTextContains("192.168.1.100:6000", substring = true) - compose.onNodeWithText("IP / 포트 설정").performClick() - compose.onNodeWithText("장비 기본값").performClick() - compose.onNodeWithTag("wifi:tcp-save").performClick() - compose.runOnIdle { assertEquals(WifiApEndpoint(), savedEndpoint) } - } - - @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").assertDoesNotExist() - compose.onNodeWithTag("wifi:tcp-save").performClick() - compose.runOnIdle { - assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint) - 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").assertDoesNotExist() - compose.onNodeWithTag("wifi:tcp-save").performClick() - compose.runOnIdle { - assertEquals(7000, savedEndpoint!!.port) - assertEquals(0, current) - } - } - - @Test fun tcpSettingsCancelAndMeasurementGuardPreserveStoredTarget() { - show() - compose.onNodeWithContentDescription("더보기").performClick() - compose.onNodeWithText("IP / 포트 설정").performClick() - compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100") - compose.onNodeWithText("취소").performClick() - compose.runOnIdle { - assertNull(savedEndpoint); assertEquals(WifiApEndpoint(), endpoint.value) - endpointEditable.value = false - } - compose.onNodeWithContentDescription("더보기").performClick() - compose.onNodeWithText("IP / 포트 설정").assertIsNotEnabled() - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt deleted file mode 100644 index 8bb5159..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.dct.app.presentation.dialog - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.SideEffect -import androidx.compose.material3.Text -import androidx.compose.foundation.layout.Column -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import com.dct.app.data.communication.WifiConnectionMode -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -import org.junit.Rule -import org.junit.Test - -class WifiConnectionModeUiTest { - @get:Rule val compose = createComposeRule() - - @Test fun changingTabsKeepsTheSameDialogWindowBoundsAndTabPosition() { - var mode by mutableStateOf(WifiConnectionMode.TCP) - var windowView: android.view.View? = null - compose.setContent { MaterialTheme { - WifiConnectionDialogFrame(mode, true, { mode=it }, {}) { - val rootView = LocalView.current.rootView - SideEffect { windowView=rootView } - // Different content heights must not resize or replace the parent window. - if (mode == WifiConnectionMode.TCP) Text("AP 목록") else Column { - repeat(10) { Text("Direct 기기 $it") } - } - } - } } - val initialWindow = windowView - val initialBounds = compose.onNodeWithTag("wifi:dialog").fetchSemanticsNode().boundsInRoot - val tabBounds = compose.onNodeWithTag("wifi-mode:TCP").fetchSemanticsNode().boundsInRoot - repeat(3) { - compose.onNodeWithTag("wifi-mode:DIRECT").performClick().assertIsSelected() - compose.onNodeWithText("Direct 기기 0").assertIsDisplayed() - compose.onNodeWithText("AP 목록").assertDoesNotExist() - assertEquals(initialBounds, compose.onNodeWithTag("wifi:dialog").fetchSemanticsNode().boundsInRoot) - assertEquals(tabBounds, compose.onNodeWithTag("wifi-mode:TCP").fetchSemanticsNode().boundsInRoot) - compose.runOnIdle { assertSame(initialWindow, windowView) } - compose.onNodeWithTag("wifi-mode:TCP").performClick().assertIsSelected() - compose.onNodeWithText("AP 목록").assertIsDisplayed() - assertEquals(initialBounds, compose.onNodeWithTag("wifi:dialog").fetchSemanticsNode().boundsInRoot) - compose.runOnIdle { assertSame(initialWindow, windowView) } - } - compose.onAllNodes(isDialog()).assertCountEquals(1) - } - - @Test fun tabsSwitchModesAndLockDuringAnActiveLink() { - var mode by mutableStateOf(WifiConnectionMode.TCP) - var enabled by mutableStateOf(true) - var clicks=0 - compose.setContent { MaterialTheme { - WifiConnectionModeSelector(mode,enabled) { mode=it; clicks++ } - } } - compose.onNodeWithTag("wifi-mode:TCP").assertIsSelected() - compose.onNodeWithTag("wifi-mode:DIRECT").performClick().assertIsSelected() - compose.runOnIdle { enabled=false } - compose.onNodeWithTag("wifi-mode:TCP").assertIsNotEnabled() - compose.onNodeWithText("Wi-Fi 해제 후 전환할 수 있습니다.").assertIsDisplayed() - compose.runOnIdle { assertEquals(1,clicks); enabled=true } - compose.onNodeWithTag("wifi-mode:TCP").performClick().assertIsSelected() - compose.runOnIdle { assertEquals(2,clicks) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt deleted file mode 100644 index 144f40c..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.dct.app.presentation.dialog - -import android.net.wifi.p2p.WifiP2pDevice -import android.net.wifi.p2p.WifiP2pGroup -import android.net.wifi.p2p.WifiP2pInfo -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.compose.ui.unit.dp -import androidx.test.platform.app.InstrumentationRegistry -import com.dct.app.data.communication.* -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import java.io.File - -class WifiDirectDeviceListUiTest { - @get:Rule val compose = createComposeRule() - private val peer = WifiP2pDevice().apply { - deviceName = "DCT_TEST_PEER" - deviceAddress = "02:00:00:00:00:01" - status = WifiP2pDevice.AVAILABLE - } - private val state = mutableStateOf(WifiDirectUiState(phase = WifiDirectPhase.IDLE, enabled = true, - permissionGranted = true, locationEnabled = true, synchronizedWithOs = true, peers = listOf(peer))) - private var disconnected = 0 - private var portSettings = 0 - private var cancelled = 0 - private var selected: WifiP2pDevice? = null - private var refreshed = 0 - private var dismissed = 0 - - private fun link() = WifiDirectLink(WifiP2pInfo().apply { groupFormed = true; isGroupOwner = true }, WifiP2pGroup()) - private fun show() { - compose.setContent { MaterialTheme { - WifiConnectionDialogFrame(WifiConnectionMode.DIRECT, state.value.link == null, {}, {}) { - WifiDirectDeviceListContent(state.value, 5000, state.value.link == null, - onPortSettings = { portSettings++ }, onPermissions = {}, onSettings = {}, - onDisconnect = { disconnected++ }, onCancel = { cancelled++ }, onConnect = { selected=it }, - onRefresh = { refreshed++ }, onDismiss = { dismissed++ }) - } - } } - } - - @Test fun connectedSummaryKeepsStatusUnderNameAndDisconnectOnRightWithRoomForDiscovery() { - state.value = state.value.copy(phase = WifiDirectPhase.CONNECTED, link = link(), peers = (1..10).map { i -> - WifiP2pDevice().apply { deviceName="DCT_PEER_$i"; deviceAddress="peer_$i"; status=WifiP2pDevice.AVAILABLE } - }) - show() - val name = compose.onNodeWithText("Wi-Fi Direct 그룹").assertIsDisplayed().fetchSemanticsNode().boundsInRoot - val status = compose.onNodeWithTag("wifi:state").assertTextEquals("연결됨").fetchSemanticsNode().boundsInRoot - val disconnect = compose.onNodeWithTag("wifi:direct-disconnect").assertIsEnabled().assertIsDisplayed() - val row = compose.onNodeWithTag("wifi:direct-current").fetchSemanticsNode().boundsInRoot - val list = compose.onNodeWithTag("wifi:direct-list").assertIsDisplayed().fetchSemanticsNode().boundsInRoot - assertTrue(status.top >= name.bottom) - assertTrue(name.right <= disconnect.fetchSemanticsNode().boundsInRoot.left) - with(compose.density) { - assertTrue("Connected summary should fit one compact row", row.height <= 64.dp.toPx()) - assertTrue("Discovery list must remain usable after connecting", list.height >= 200.dp.toPx()) - } - compose.onAllNodesWithText("연결됨").assertCountEquals(1) - compose.onNodeWithText("DCT_PEER_1").assertIsDisplayed() - compose.onNodeWithText("포트 설정").assertIsNotEnabled() - val bitmap = compose.onNodeWithTag("wifi:dialog").captureToImage().asAndroidBitmap() - File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "wifi-direct-compact.png") - .outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } - disconnect.performClick() - compose.runOnIdle { assertEquals(1, disconnected); assertNull(selected) } - } - - @Test fun disconnectingShowsProgressInTheSameRowAndBlocksRepeatedDisconnect() { - state.value = state.value.copy(phase = WifiDirectPhase.DISCONNECTING, link = link()) - show() - compose.onNodeWithTag("wifi:state").assertTextEquals("연결 해제 중…") - compose.onNodeWithTag("wifi:direct-disconnect").assertIsNotEnabled().performClick() - compose.runOnIdle { assertEquals(0, disconnected) } - compose.onNodeWithText("닫기").assertIsDisplayed() - } - - @Test fun compactLayoutRetainsConnectCancelPortAndFooterActions() { - show() - compose.onNodeWithText("포트 설정").assertIsEnabled().performClick() - compose.onNodeWithText("연결").assertIsEnabled().performClick() - compose.runOnIdle { assertEquals(1, portSettings); assertSame(peer, selected) } - compose.runOnIdle { state.value=state.value.copy(phase=WifiDirectPhase.CONNECTING, target=peer) } - compose.onNodeWithText("취소").assertIsEnabled().performClick() - compose.runOnIdle { assertEquals(1, cancelled); state.value=state.value.copy(phase=WifiDirectPhase.IDLE, target=null) } - compose.onNodeWithText("새로고침").assertIsEnabled().performClick() - compose.onNodeWithText("닫기").performClick() - compose.runOnIdle { assertEquals(1, refreshed); assertEquals(1, dismissed) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt deleted file mode 100644 index db95b66..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.dct.app.presentation.dialog - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class WifiDirectPortUiTest { - @get:Rule val compose=createComposeRule() - @Test fun default5000CanBeChangedAndSaved() { - var visible by mutableStateOf(true) - var saved:Int?=null - compose.setContent { MaterialTheme { - if (visible) WifiDirectPortDialog(5000,true,{ saved=it; true },{ visible=false }) - } } - compose.onNodeWithTag("wifi-direct-port").assertTextContains("5000").performTextReplacement("6000") - compose.onNodeWithText("저장").performClick() - compose.onNodeWithText("Direct 포트 설정").assertDoesNotExist() - compose.runOnIdle { assertEquals(6000,saved) } - } - - @Test fun invalidPortsAreRejectedAndCancelDoesNotSave() { - var calls=0 - var visible by mutableStateOf(true) - compose.setContent { MaterialTheme { - if (visible) WifiDirectPortDialog(5000,true,{ calls++; true },{ visible=false }) - } } - for (invalid in listOf("","0","65536","abc","999999999999999")) { - compose.onNodeWithTag("wifi-direct-port").performTextReplacement(invalid) - compose.onNodeWithText("저장").assertIsNotEnabled() - compose.onNodeWithText("1~65535 사이의 숫자를 입력하세요.").assertIsDisplayed() - } - compose.onNodeWithText("취소").performClick() - compose.runOnIdle { assertEquals(0,calls) } - } - - @Test fun activeCommunicationDisablesSavingAndRejectedSaveKeepsDialogOpen() { - var canEdit by mutableStateOf(false) - compose.setContent { MaterialTheme { WifiDirectPortDialog(5000,canEdit,{ false },{}) } } - compose.onNodeWithText("저장").assertIsNotEnabled() - compose.runOnIdle { canEdit=true } - compose.onNodeWithText("저장").performClick() - compose.onNodeWithText("통신 상태가 변경되어 저장하지 못했습니다. 연결을 해제한 뒤 다시 시도하세요.").assertIsDisplayed() - compose.onNodeWithText("Direct 포트 설정").assertIsDisplayed() - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ChartNavigationUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ChartNavigationUiTest.kt deleted file mode 100644 index 1de655e..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ChartNavigationUiTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.dp -import com.dct.app.domain.model.chart.ChartRenderSnapshot -import com.dct.app.presentation.chart.MultiXGraphCanvas -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class ChartNavigationUiTest { - @get:Rule val compose = createComposeRule() - - @Test fun doubleTapInvokesTheLatestResetCallbackForBothGraphTypesWithoutPanning() { - val multiAxis = mutableStateOf(false) - val version = mutableStateOf(1) - var lastResetVersion = 0 - var pans = 0 - compose.setContent { - val capturedVersion = version.value - Box(Modifier.size(500.dp, 300.dp).testTag("graph")) { - MultiXGraphCanvas(ChartRenderSnapshot(0.0, 60.0, emptyList(), emptyMap(), legendPosition = 3), - isMultiAxis = multiAxis.value, onResetPan = { lastResetVersion = capturedVersion }, - onPanTime = { pans++ }) - } - } - for (multi in listOf(false, true)) { - compose.runOnIdle { multiAxis.value = multi; lastResetVersion = 0 } - // Recompose without restarting the pointerInput key: the callback must not go stale. - compose.runOnIdle { version.value++ } - compose.onNodeWithTag("graph").performTouchInput { doubleClick(center) } - compose.runOnIdle { assertEquals(version.value, lastResetVersion); assertEquals(0, pans) } - } - } - - @Test fun tinyFingerMovementKeepsTapWhileRealDragsPanInBothDirections() { - val pans = mutableListOf() - compose.setContent { - Box(Modifier.size(500.dp, 300.dp).testTag("graph")) { - MultiXGraphCanvas(ChartRenderSnapshot(0.0, 60.0, emptyList(), emptyMap(), legendPosition = 3), - onPanTime = { pans.add(it) }) - } - } - compose.onNodeWithTag("graph").performTouchInput { - down(center) - moveTo(center + Offset(1f, 0f)) - up() - } - compose.runOnIdle { assertTrue(pans.isEmpty()) } - compose.onNodeWithTag("graph").performTouchInput { - advanceEventTime(500) - swipe(center, center + Offset(100f, 0f), 300) - } - compose.runOnIdle { assertTrue(pans.sum() < 0.0); pans.clear() } - compose.onNodeWithTag("graph").performTouchInput { - swipe(center, center - Offset(100f, 0f), 300) - } - compose.runOnIdle { assertTrue(pans.sum() > 0.0) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/CompensationLayoutUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/CompensationLayoutUiTest.kt deleted file mode 100644 index e9608fa..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/CompensationLayoutUiTest.kt +++ /dev/null @@ -1,167 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toPixelMap -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import com.dct.app.domain.model.mdb.* -import com.dct.app.presentation.signaltree.* -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class CompensationLayoutUiTest { - @get:Rule val compose = createComposeRule() - private fun map(typeA: Boolean) = CompensationMap( - if (typeA) CompensationMapType.TYPE_A else CompensationMapType.TYPE_B, - 1, "Y", 0, MdbAttrType.ATTR_3_INT16, 0.0, 100.0, - "X", 0, MdbAttrType.ATTR_3_INT16, 1.0, 0.0, - points = listOf(MapPoint(0, 0, -100, -50.0), MapPoint(1, 100, 200, 150.0)) - ) - - @Test fun mapHeadersFitAtLargeFontScaleAndNarrowWidthForBothTypes() { - val typeA = mutableStateOf(true) - compose.setContent { - val density = LocalDensity.current - CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) { - MaterialTheme { - CompensationTable(compensationMapColumns(typeA.value), listOf(map(typeA.value)), - "test:map", Modifier.size(280.dp, 140.dp), onRowClick = { _, _ -> }) - } - } - } - for (a in listOf(true, false)) { - compose.runOnIdle { typeA.value = a } - for (column in compensationMapColumns(a)) { - compose.onNodeWithText(column.title).performScrollTo().assertIsDisplayed() - val layouts = mutableListOf() - compose.onNodeWithText(column.title).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } - assertTrue(column.title, layouts.isNotEmpty()) - assertFalse(column.title, layouts.single().hasVisualOverflow) - } - } - } - - @Test fun pointHeadersScrollInsteadOfShrinkingAndRetainRowClick() { - val typeA = mutableStateOf(true) - var clicked = -1 - compose.setContent { - MaterialTheme { - CompensationTable(compensationPointColumns(typeA.value), map(typeA.value).points, - "test:point", Modifier.size(120.dp, 140.dp), onRowClick = { index, _ -> clicked = index }) - } - } - for (a in listOf(true, false)) { - compose.runOnIdle { typeA.value = a } - for (column in compensationPointColumns(a)) { - compose.onNodeWithText(column.title).performScrollTo().assertIsDisplayed() - val layouts = mutableListOf() - compose.onNodeWithText(column.title).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } - assertFalse(layouts.single().hasVisualOverflow) - } - } - compose.onNodeWithText("-50").performClick() - compose.runOnIdle { assertEquals(0, clicked) } - } - - @Test fun blankDescriptionCanBeEditedAndSavedWhileEditorStillHasFocus() { - val rows = mutableStateOf(listOf(map(true).copy(description = ""))) - val visible = mutableStateOf(true) - var descriptionInProject = "" - compose.setContent { - if (visible.value) MaterialTheme { - CompensationTable(compensationMapColumns(true), rows.value, "test:edit", - Modifier.size(280.dp, 140.dp), onRowClick = { _, _ -> }, - onDescriptionChange = { _, text -> - descriptionInProject = text - rows.value = listOf(rows.value.single().copy(description = text)) - }) - } - } - compose.onNodeWithTag("test:edit:description:0").performScrollTo().performClick() - compose.onNode(hasSetTextAction()).performTextReplacement("새 보정 설명") - compose.onNode(hasSetTextAction()).assertIsFocused() - compose.runOnIdle { - val project = com.dct.app.domain.model.project.DpcProject( - parameterDescriptions = mapOf("map_TYPE_A:Y" to descriptionInProject)) - val json = com.dct.app.data.repository.DpcProjectManager.serializeProjectToJson(project) - assertEquals("새 보정 설명", com.dct.app.data.repository.DpcProjectManager - .parseProjectFromJson(json, null).parameterDescriptions["map_TYPE_A:Y"]) - } - compose.onNode(hasSetTextAction()).performTextClearance() - compose.runOnIdle { assertEquals("", descriptionInProject); visible.value = false } - compose.runOnIdle { assertEquals("", descriptionInProject) } - } - - @Test fun curvesCoincidentWithEachAxisRemainVisibleForBothTypes() { - val current = mutableStateOf(map(true)) - compose.setContent { - CompensationCurveCanvas(current.value, Modifier.size(300.dp, 200.dp) - .background(Color.White).testTag("edge-curve")) - } - val edges = listOf( - listOf(0 to 0, 50 to 0, 100 to 0), - listOf(0 to 100, 50 to 100, 100 to 100), - listOf(0 to 0, 0 to 50, 100 to 100), - listOf(0 to 0, 100 to 50, 100 to 100) - ) - for (a in listOf(true, false)) for ((edge, points) in edges.withIndex()) { - compose.runOnIdle { - current.value = map(a).copy(points = points.mapIndexed { i, (x, y) -> - MapPoint(i, x, y, if (a) x.toDouble() else y.toDouble()) - }) - } - val pixels = compose.onNodeWithTag("edge-curve").captureToImage().toPixelMap() - val b = compensationPlotBounds(pixels.width.toFloat(), pixels.height.toFloat())!! - var edgePixels = 0 - for (y in 0 until pixels.height) for (x in 0 until pixels.width) { - val c = pixels[x, y] - val colored = if (a) c.red > 0.6f && c.green < 0.4f && c.blue < 0.4f - else c.blue > 0.6f && c.green < 0.4f && c.red < 0.4f - val nearEdge = when (edge) { - 0 -> y >= b.bottom - 2f && y < b.bottom - 1 -> y >= b.top && y < b.top + 2f - 2 -> x >= b.left && x < b.left + 2f - else -> x >= b.right - 2f && x < b.right - } - if (colored && nearEdge) edgePixels++ - } - assertTrue("Type A=$a, edge=$edge must remain visible", edgePixels > 10) - } - } - - @Test fun compensationCurvePixelsNeverEscapePlotFrame() { - val typeA = mutableStateOf(true) - compose.setContent { - CompensationCurveCanvas(map(typeA.value), Modifier.size(300.dp, 200.dp) - .background(Color.White).testTag("curve")) - } - for (a in listOf(true, false)) { - compose.runOnIdle { typeA.value = a } - val pixels = compose.onNodeWithTag("curve").captureToImage().toPixelMap() - val bounds = compensationPlotBounds(pixels.width.toFloat(), pixels.height.toFloat())!! - var curvePixels = 0 - for (y in 0 until pixels.height) for (x in 0 until pixels.width) { - val c = pixels[x, y] - val isCurve = if (a) c.red > 0.6f && c.green < 0.4f && c.blue < 0.4f - else c.blue > 0.6f && c.green < 0.4f && c.red < 0.4f - if (isCurve) { - curvePixels++ - assertTrue("Curve escaped at $x,$y", x >= bounds.left && x < bounds.right && y >= bounds.top && y < bounds.bottom) - } - } - assertTrue("Curve must actually be rendered", curvePixels > 0) - } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ConstantWindowUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ConstantWindowUiTest.kt deleted file mode 100644 index 1d8d427..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ConstantWindowUiTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import com.dct.app.domain.model.mdb.* -import com.dct.app.presentation.signaltree.* -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class ConstantWindowUiTest { - @get:Rule val compose = createComposeRule() - private val constant = MdbConstant(1, "Torque", 350.0, 0x1000, 0.0, 500.0, MdbAttrType.ATTR_2_UINT16) - - @Test fun onlyValueCellOpensEditorAndNackPreservesInputUntilRetry() { - var completion: ((String?) -> Unit)? = null - val rows = mutableStateOf(listOf(constant)) - compose.setContent { - MaterialTheme { - WindowsClassicConstantTableWindow(rows.value, modifier = Modifier.size(550.dp, 300.dp), - canRequestDevice = true, onValueChange = { _, _, done -> completion = done }) - } - } - compose.onNodeWithTag("constant:name:1").performTouchInput { click() } - compose.onNodeWithTag("constant:input").assertDoesNotExist() - compose.onNodeWithTag("constant:value:1").performClick() - compose.onNodeWithTag("constant:input").performTextReplacement("400.5") - compose.onNodeWithTag("constant:write").assertIsNotEnabled() - compose.onNodeWithTag("constant:input").performTextReplacement("400") - compose.onNodeWithTag("constant:write").performClick() - compose.onNodeWithText("장비 응답 대기 중").assertIsDisplayed() - compose.onNodeWithTag("constant:write").assertIsNotEnabled() - compose.runOnIdle { completion!!("NACK") } - compose.onNodeWithText("NACK").assertIsDisplayed() - compose.onNodeWithTag("constant:input").assert(SemanticsMatcher.expectValue(androidx.compose.ui.semantics.SemanticsProperties.EditableText, androidx.compose.ui.text.AnnotatedString("400"))) - compose.onNodeWithTag("constant:write").performClick() - compose.runOnIdle { rows.value = listOf(constant.copy(data = 400.0)); completion!!(null) } - compose.onNodeWithText("장비에 값을 반영했습니다.").assertIsDisplayed() - } - - @Test fun deviceButtonsAreDisabledUntilReadyAndReadRefreshesEditor() { - val ready = mutableStateOf(false) - val rows = mutableStateOf(listOf(constant)) - compose.setContent { - MaterialTheme { - WindowsClassicConstantTableWindow(rows.value, modifier = Modifier.size(550.dp, 300.dp), - canRequestDevice = ready.value, onRead = { _, done -> rows.value = listOf(constant.copy(data = 401.0)); done(null) }) - } - } - compose.onNodeWithTag("constant:value:1").performClick() - compose.onNodeWithTag("constant:read").assertIsNotEnabled() - compose.runOnIdle { ready.value = true } - compose.onNodeWithTag("constant:read").performClick() - compose.onNodeWithTag("constant:input").assert(SemanticsMatcher.expectValue(androidx.compose.ui.semantics.SemanticsProperties.EditableText, androidx.compose.ui.text.AnnotatedString("401"))) - } - - @Test fun expandedHeadersFitAtLargeFontAndCheckedFilterRestores() { - var saved = emptyMap() - val visible = mutableStateOf(true) - compose.setContent { - val density = LocalDensity.current - CompositionLocalProvider(LocalDensity provides Density(density.density, 1.5f)) { - if (visible.value) MaterialTheme { - WindowUiStateProvider("constant", saved, { saved = it }) { - WindowsClassicConstantTableWindow(listOf(constant, constant.copy(idx = 2, name = "RPM")), - modifier = Modifier.size(340.dp, 300.dp)) - } - } - } - } - compose.onNodeWithText("Expand").performClick() - for (key in listOf("name", "value", "desc", "min", "max", "addr")) { - compose.onNodeWithTag("constant:header:$key").performScrollTo() - val title = when (key) { "desc" -> "Description"; "addr" -> "Address"; else -> key.replaceFirstChar { it.uppercase() } } - val layouts = mutableListOf() - compose.onNodeWithText(title).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } - assertFalse(title, layouts.single().hasVisualOverflow) - } - compose.onNodeWithTag("constant:check:2").performScrollTo().performClick() - compose.onNodeWithTag("constant:only-checked").performClick() - compose.onNodeWithTag("constant:name:1").assertDoesNotExist() - compose.runOnIdle { visible.value = false } - compose.waitForIdle() - compose.runOnIdle { visible.value = true } - compose.onNodeWithText("Collapse").assertExists() - compose.onNodeWithTag("constant:only-checked").assertIsOn() - compose.onNodeWithTag("constant:check:2").assertIsOn() - compose.onNodeWithTag("constant:name:1").assertDoesNotExist() - } - - @Test fun emptyDescriptionCommitsBeforeFocusIsLost() { - val rows = mutableStateOf(listOf(constant)) - var savedDescription = "" - compose.setContent { - MaterialTheme { - WindowsClassicConstantTableWindow(rows.value, modifier = Modifier.size(550.dp, 300.dp), - onDescriptionChange = { _, text -> savedDescription = text; rows.value = listOf(constant.copy(description = text)) }) - } - } - compose.onNodeWithTag("constant:desc:1").performTouchInput { click(center) } - compose.onNode(hasSetTextAction() and isFocused()).performTextReplacement("상수 설명") - compose.runOnIdle { assertEquals("상수 설명", savedDescription) } - compose.onNode(hasSetTextAction() and isFocused()).performTextClearance() - compose.runOnIdle { assertEquals("", savedDescription) } - } -} 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 deleted file mode 100644 index 40916a4..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DebugValueResetUiTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -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/DeveloperOptionsDialogUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialogUiTest.kt deleted file mode 100644 index 95db0f5..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialogUiTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -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 DeveloperOptionsDialogUiTest { - @get:Rule val compose = createComposeRule() - - @Test fun switchShowsCurrentSettingAndChangesItWithoutClosingTheDialog() { - var enabled by mutableStateOf(true) - var visible by mutableStateOf(true) - val changes = mutableListOf() - compose.setContent { - if (visible) DeveloperOptionsDialog(enabled, { enabled = it; changes += it }, { visible = false }) - } - compose.onNodeWithText("개발자 옵션").assertIsDisplayed() - compose.onNodeWithTag("ReceiveAutoRecoverySwitch").assertIsOn().performClick() - compose.onNodeWithTag("ReceiveAutoRecoverySwitch").assertIsOff() - compose.onNodeWithText("꺼짐").assertIsDisplayed() - compose.onNodeWithTag("ReceiveAutoRecoverySwitch").performClick().assertIsOn() - compose.onNodeWithText("켜짐").assertIsDisplayed() - compose.runOnIdle { assertEquals(listOf(false, true), changes) } - compose.onNodeWithText("닫기").performClick() - compose.onNodeWithText("개발자 옵션").assertDoesNotExist() - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt deleted file mode 100644 index bba73c7..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import com.dct.app.core.theme.SignalColorPresets -import com.dct.app.domain.model.formula.FormulaDefinition -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test - -class FormulaColorUiTest { - @get:Rule val compose = createComposeRule() - - @Test fun all24PresetsExistAndNewPresetIsSavedInPcFormat() { - var saved: FormulaDefinition? = null - compose.setContent { - FormulaEditDialog(initialFormula = FormulaDefinition(name = "F", expression = "2"), - onDismiss = {}, onSave = { saved = it }) - } - SignalColorPresets.colors.forEach { color -> - compose.onNodeWithTag("FormulaColorPreset_${color.toString(16).padStart(6, '0')}").assertExists() - } - val color = SignalColorPresets.colors.last() - compose.onNodeWithTag("FormulaColorPreset_${color.toString(16).padStart(6, '0')}").performScrollTo().performClick() - compose.onNodeWithText("저장").performScrollTo().performClick() - compose.runOnIdle { assertEquals(color, saved?.color) } - } - - @Test fun customColorPickerOpensAndCancelLeavesOriginalColor() { - var saved: FormulaDefinition? = null - val initial = SignalColorPresets.colors.first() - compose.setContent { - FormulaEditDialog(initialFormula = FormulaDefinition(name = "F", expression = "2", color = initial), - onDismiss = {}, onSave = { saved = it }) - } - compose.onNodeWithText("사용자 지정 색상").performScrollTo().performClick() - compose.onNodeWithText("Signal Color").assertExists() - compose.onNodeWithText("Cancel").performClick() - compose.onNodeWithText("저장").performScrollTo().performClick() - compose.runOnIdle { assertEquals(initial, saved?.color) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt deleted file mode 100644 index 27982e5..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt +++ /dev/null @@ -1,258 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.dp -import androidx.compose.ui.graphics.toPixelMap -import com.dct.app.core.theme.SignalColorPresets -import com.dct.app.domain.model.formula.FormulaDefinition -import com.dct.app.presentation.chart.DraggedSignal -import com.dct.app.presentation.chart.SignalDragDropManager -import com.dct.app.presentation.signaltree.WindowUiStateProvider -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class FormulaColumnsUiTest { - @get:Rule val compose = createComposeRule() - private val formula = FormulaDefinition(id = "f", name = "Long Formula Name", expression = "Torque*2", unit = "kW") - - @Test fun nameAndColorAreSeparateAndEveryColumnSharesHeaderAndRowWidth() { - val widths = linkedMapOf("name" to "170.0", "value" to "90.0", "unit" to "65.0", "color" to "70.0", "expression" to "180.0") - compose.setContent { - WindowUiStateProvider("formula", widths.mapKeys { "width:${it.key}" }, {}) { - Box(Modifier.size(1000.dp, 300.dp)) { - FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) - } - } - } - compose.onNodeWithTag("FormulaCell_name_f", useUnmergedTree = true).onChildren().filterToOne(hasText(formula.name)).assertExists() - compose.onNodeWithTag("FormulaCell_unit_f", useUnmergedTree = true).onChildren().filterToOne(hasText(formula.unit)).assertExists() - compose.onNodeWithText("Expand").assertDoesNotExist() - compose.onNodeWithText("Collapse").assertDoesNotExist() - compose.onNodeWithText("Act").assertDoesNotExist() - compose.onNodeWithTag("FormulaHeader_act").assertDoesNotExist() - compose.onNodeWithTag("FormulaCell_act_f", useUnmergedTree = true).assertDoesNotExist() - compose.onNodeWithTag("FormulaActions_f").assertIsDisplayed() - widths.forEach { (key, width) -> - compose.onNodeWithTag("FormulaHeader_$key").assertWidthIsEqualTo(width.toFloat().dp) - compose.onNodeWithTag("FormulaCell_${key}_f", useUnmergedTree = true).assertWidthIsEqualTo(width.toFloat().dp) - } - val keys = widths.keys.toList() - keys.zipWithNext().forEach { (left, right) -> - val leftHeader = compose.onNodeWithTag("FormulaHeader_$left").fetchSemanticsNode().boundsInRoot - val rightHeader = compose.onNodeWithTag("FormulaHeader_$right").fetchSemanticsNode().boundsInRoot - val leftCell = compose.onNodeWithTag("FormulaCell_${left}_f", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val rightCell = compose.onNodeWithTag("FormulaCell_${right}_f", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertTrue("Header order: $left before $right", leftHeader.left < rightHeader.left) - assertTrue("Row order: $left before $right", leftCell.left < rightCell.left) - } - } - - @Test fun actionsStayPinnedWhileHorizontalAndVerticalScrolling() { - val rows = (0..20).map { formula.copy(id = "f$it", name = "Formula $it") } - compose.setContent { - WindowUiStateProvider("formula", mapOf("width:expression" to "1200.0", "width:act" to "1200.0"), {}) { - Box(Modifier.size(300.dp, 250.dp)) { - FormulaWindowContent(rows, onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) - } - } - } - val before = compose.onNodeWithTag("FormulaActions_f0").fetchSemanticsNode().boundsInRoot - compose.onNodeWithText("Act").assertDoesNotExist() - compose.onNodeWithTag("FormulaActions_f0").assertWidthIsEqualTo(48.dp).assertHeightIsEqualTo(48.dp).assertIsDisplayed() - compose.onNodeWithTag("FormulaViewport_f0").performTouchInput { swipeLeft(durationMillis = 200) } - val after = compose.onNodeWithTag("FormulaActions_f0").fetchSemanticsNode().boundsInRoot - assertEquals(before, after) - val headerScroll = compose.onNodeWithTag("FormulaHeaderViewport").fetchSemanticsNode() - .config[SemanticsProperties.HorizontalScrollAxisRange].value() - val rowScroll = compose.onNodeWithTag("FormulaViewport_f0").fetchSemanticsNode() - .config[SemanticsProperties.HorizontalScrollAxisRange].value() - assertTrue(headerScroll > 0f) - assertEquals(headerScroll, rowScroll, 1f) - compose.onNodeWithTag("FormulaHeaderViewport").performSemanticsAction(SemanticsActions.ScrollBy) { it(10000f, 0f) } - val expressionEnd = compose.onNodeWithTag("FormulaCell_expression_f0", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.right - val maskStart = compose.onNodeWithTag("FormulaMenuBackdrop_f0").fetchSemanticsNode().boundsInRoot.left - assertTrue("Last expression must scroll clear of the fade mask", expressionEnd <= maskStart + 1f) - val finalScroll = compose.onNodeWithTag("FormulaHeaderViewport").fetchSemanticsNode() - .config[SemanticsProperties.HorizontalScrollAxisRange].value() - compose.onNodeWithTag("FormulaRows").performScrollToIndex(10) - compose.onNodeWithTag("FormulaActions_f10").assertIsDisplayed() - val scrolled = compose.onNodeWithTag("FormulaActions_f10").fetchSemanticsNode().boundsInRoot - assertEquals(before.left, scrolled.left, 1f) - assertEquals(before.right, scrolled.right, 1f) - assertEquals(finalScroll, compose.onNodeWithTag("FormulaViewport_f10").fetchSemanticsNode() - .config[SemanticsProperties.HorizontalScrollAxisRange].value(), 1f) - compose.onNodeWithTag("FormulaActions_f10").performClick() - compose.onNodeWithText("수식 삭제").performClick() - compose.onNodeWithText("‘Formula 10’ 수식을 삭제하시겠습니까?").assertExists() - } - - @Test fun menuBackdropSoftensTextAreaAndMatchesEachRowBackground() { - val rows = listOf(formula, formula.copy(id = "second", name = "Second")) - compose.setContent { - Box(Modifier.size(300.dp, 250.dp)) { - FormulaWindowContent(rows, onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) - } - } - compose.onNodeWithText("Act").assertDoesNotExist() - rows.forEachIndexed { index, row -> - val backdrop = compose.onNodeWithTag("FormulaMenuBackdrop_${row.id}") - backdrop.assertWidthIsEqualTo(82.dp) - val pixels = backdrop.captureToImage().toPixelMap() - val fade = pixels[(pixels.width * 0.25f).toInt(), (pixels.height * 0.15f).toInt()] - val opaque = pixels[(pixels.width * 0.65f).toInt(), (pixels.height * 0.15f).toInt()] - // The Color cell below the transition still tints the fade, but never the button area. - assertTrue(fade.blue > fade.red + 0.01f) - val expected = if (index == 0) androidx.compose.ui.graphics.Color.White else androidx.compose.ui.graphics.Color(0xFFF8F9FA) - assertEquals(expected.red, opaque.red, 0.01f) - assertEquals(expected.green, opaque.green, 0.01f) - assertEquals(expected.blue, opaque.blue, 0.01f) - compose.onNodeWithTag("FormulaActions_${row.id}").assertIsDisplayed() - } - } - - @Test fun actionMenuEditsWithoutDeletingAndDeleteRequiresConfirmation() { - var updated: FormulaDefinition? = null - val deleted = mutableListOf() - compose.setContent { - Box(Modifier.size(300.dp, 250.dp)) { - FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = { updated = it }, - onDeleteFormula = { deleted.add(it) }) - } - } - compose.onNodeWithTag("FormulaActions_f").performClick() - compose.onNodeWithText("수식 수정").performClick() - compose.onNodeWithText("저장").performScrollTo().performClick() - compose.runOnIdle { assertEquals(formula, updated); assertTrue(deleted.isEmpty()) } - compose.onNodeWithTag("FormulaActions_f").performClick() - compose.onNodeWithText("수식 삭제").performClick() - compose.runOnIdle { assertTrue(deleted.isEmpty()) } - compose.onNodeWithText("‘${formula.name}’ 수식을 삭제하시겠습니까?").assertExists() - compose.onNodeWithText("취소").performClick() - compose.runOnIdle { assertTrue(deleted.isEmpty()) } - compose.onNodeWithTag("FormulaConfirmDelete").assertDoesNotExist() - compose.onNodeWithTag("FormulaActions_f").performClick() - compose.onNodeWithText("수식 삭제").performClick() - compose.onNodeWithTag("FormulaConfirmDelete").performClick() - compose.runOnIdle { assertEquals(listOf(formula), deleted) } - compose.onNodeWithTag("FormulaConfirmDelete").assertDoesNotExist() - } - - @Test fun pendingDeleteTracksLatestDefinitionAndDisappearsIfRemoved() { - val rows = mutableStateOf(listOf(formula)) - val deleted = mutableListOf() - compose.setContent { - Box(Modifier.size(300.dp, 250.dp)) { - FormulaWindowContent(rows.value, onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = { deleted.add(it) }) - } - } - compose.onNodeWithTag("FormulaActions_f").performClick() - compose.onNodeWithText("수식 삭제").performClick() - val changed = formula.copy(name = "Renamed", expression = "Torque*3") - compose.runOnIdle { rows.value = listOf(changed) } - compose.onNodeWithText("‘Renamed’ 수식을 삭제하시겠습니까?").assertExists() - compose.onNodeWithTag("FormulaConfirmDelete").performClick() - compose.runOnIdle { assertEquals(listOf(changed), deleted) } - compose.onNodeWithTag("FormulaActions_f").performClick() - compose.onNodeWithText("수식 삭제").performClick() - compose.runOnIdle { rows.value = emptyList() } - compose.onNodeWithTag("FormulaConfirmDelete").assertDoesNotExist() - compose.runOnIdle { assertEquals(listOf(changed), deleted) } - } - - @Test fun scrolledDataStillLongPressDragsAtTheVisibleTouchPosition() { - var dropped: DraggedSignal? = null - var droppedAt: Offset? = null - compose.setContent { - Box(Modifier.size(300.dp, 250.dp)) { - FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}, - onSignalDrop = { dropped = it; droppedAt = SignalDragDropManager.dragPosition }) - } - } - compose.onNodeWithTag("FormulaViewport_f").performTouchInput { swipeLeft(durationMillis = 200) } - val bounds = compose.onNodeWithTag("FormulaViewport_f").fetchSemanticsNode().boundsInRoot - compose.runOnIdle { - SignalDragDropManager.registerDropTarget("formula-regression", Rect(bounds.left, bounds.top, bounds.right, bounds.bottom + 100f)) - } - try { - compose.onNodeWithTag("FormulaViewport_f").performTouchInput { - down(center) - advanceEventTime(700) - moveBy(Offset(0f, 30f)) - up() - } - compose.runOnIdle { - assertEquals("formula_${formula.name}", dropped?.signalId) - assertEquals(formula.unit, dropped?.unit) - assertEquals(bounds.center.x, requireNotNull(droppedAt).x, 1f) - assertFalse(SignalDragDropManager.isDragging) - } - } finally { - compose.runOnIdle { - SignalDragDropManager.unregisterDropTarget("formula-regression") - SignalDragDropManager.cancelDrag() - } - } - } - - @Test fun oldColumnSettingsKeepWidthsAndUseDefaultUnitWidth() { - compose.setContent { - WindowUiStateProvider("formula", mapOf("width:name" to "140.0", "width:color" to "75.0"), {}) { - Box(Modifier.size(800.dp, 300.dp)) { - FormulaWindowContent(listOf(formula.copy(unit = "")), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) - } - } - } - compose.onNodeWithTag("FormulaHeader_name").assertWidthIsEqualTo(140.dp) - compose.onNodeWithTag("FormulaHeader_color").assertWidthIsEqualTo(75.dp) - compose.onNodeWithTag("FormulaHeader_unit").assertWidthIsEqualTo(45.dp) - compose.onNodeWithTag("FormulaCell_unit_f", useUnmergedTree = true).assertWidthIsEqualTo(45.dp) - compose.onNodeWithTag("FormulaCell_value_f", useUnmergedTree = true).onChildren().filterToOne(hasText("No value")).assertExists() - compose.onNodeWithText("Expand").assertDoesNotExist() - } - - @Test fun dividerDragChangesOnlyNameWidthAndRestoresAfterRecreation() { - var saved = emptyMap() - val visible = mutableStateOf(true) - compose.setContent { - if (visible.value) WindowUiStateProvider("formula", saved, { saved = it }) { - Box(Modifier.size(800.dp, 300.dp)) { - FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) - } - } - } - compose.onNodeWithTag("FormulaHeader_name").performTouchInput { - swipe(Offset(right - 3f, centerY), Offset(right + 70f, centerY), 300) - } - compose.runOnIdle { assertTrue(saved.getValue("width:name").toFloat() > 110f); assertFalse("width:color" in saved) } - compose.onNodeWithTag("FormulaCell_name_f", useUnmergedTree = true).assertWidthIsEqualTo(saved.getValue("width:name").toFloat().dp) - compose.runOnIdle { visible.value = false } - compose.waitForIdle() - compose.runOnIdle { visible.value = true } - compose.onNodeWithTag("FormulaHeader_name").assertWidthIsEqualTo(saved.getValue("width:name").toFloat().dp) - compose.onNodeWithTag("FormulaCell_name_f", useUnmergedTree = true).assertWidthIsEqualTo(saved.getValue("width:name").toFloat().dp) - } - - @Test fun colorCellAppliesNewColorWithoutChangingNameOrExpression() { - var updated: FormulaDefinition? = null - compose.setContent { - Box(Modifier.size(800.dp, 300.dp)) { - FormulaWindowContent(listOf(formula), onAddFormula = {}, onUpdateFormula = { updated = it }, onDeleteFormula = {}) - } - } - compose.onNodeWithTag("FormulaCell_color_f", useUnmergedTree = true).onChildren().filterToOne(hasClickAction()).performClick() - val color = SignalColorPresets.colors.last() - compose.onNodeWithTag("SignalColorPreset_${color.toString(16).padStart(6, '0')}").performScrollTo().performClick() - compose.onNodeWithText("Apply").performClick() - compose.runOnIdle { assertEquals(formula.copy(color = color), updated) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt deleted file mode 100644 index a7b110b..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toPixelMap -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.testTag -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 com.dct.app.domain.model.chart.* -import com.dct.app.presentation.chart.GraphYAxisDialog -import com.dct.app.presentation.chart.MultiXGraphCanvas -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class GraphSettingsUiTest { - @get:Rule val compose = createComposeRule() - private fun signal() = SeriesRenderData("a", "Signal A", AxisPosition.LEFT_0, 0xFFL, "V", 2f, - interpolationMode = InterpolationMode.LINEAR, isVisible = true, - points = listOf(1000.0 to 150.0, 1001.0 to 200.0), latestValue = 200.0, - minVisibleValue = 150.0, maxVisibleValue = 200.0) - private fun snapshot() = ChartRenderSnapshot(1000.0, 1001.0, listOf(signal()), - mapOf(AxisPosition.LEFT_0 to ChartAxisConfig(AxisPosition.LEFT_0, 0.0, 100.0, false)), - legendPosition = 3, isYAxisAuto = false, isXAxisAuto = false, xAxisPreset = TimeWindowPreset.MANUAL) - - @Composable private fun Menu(state: MutableState, open: MutableState, - onPreset: (TimeWindowPreset) -> Unit = {}) { - GraphContextMenu(expanded = open.value, onDismissRequest = { open.value = false }, - snapshot = state.value, isMultiAxis = true, - onRemoveSignal = {}, onToggleVisibility = { id -> state.value = state.value.copy( - seriesList = state.value.seriesList.map { if (it.signalId == id) it.copy(isVisible = !it.isVisible) else it }) }, - onChangeThickness = { _, _ -> open.value = false }, onChangeDashStyle = { _, _ -> open.value = false }, - onSelectLegendPosition = { state.value = state.value.copy(legendPosition = it); open.value = false }, - onSelectTimePreset = { onPreset(it); open.value = false }, onSelectXAxisManual = {}, - onSelectYAxisAuto = {}, onOpenYAxisSetting = {}, onChangeTitleName = {}, onQuitWindow = {}) - } - - @Test fun autoSubmenuDoesNotApplyUntilSelectionAndReopensAtRoot() { - val state = mutableStateOf(snapshot()) - val open = mutableStateOf(true) - var selected: TimeWindowPreset? = null - compose.setContent { Menu(state, open) { selected = it } } - compose.onNodeWithText("X-Axis").performClick() - compose.onNodeWithText("Auto").performClick() - compose.runOnIdle { assertNull(selected) } - compose.onNodeWithText("10s").assertIsDisplayed().performClick() - compose.runOnIdle { assertEquals(TimeWindowPreset.SEC_10, selected); open.value = true } - compose.onNodeWithText("X-Axis").assertIsDisplayed() - compose.onNodeWithText("Thickness").assertIsDisplayed() - } - - @Test fun showHideKeepsSignalAvailableForReshowAndLegendNoneCanBeSelected() { - val state = mutableStateOf(snapshot()) - val open = mutableStateOf(true) - compose.setContent { Menu(state, open) } - compose.onNodeWithText("Show/Hide").performClick() - compose.onNodeWithText("Signal A").performClick() - compose.runOnIdle { assertFalse(state.value.seriesList.single().isVisible) } - compose.onNodeWithText("Signal A").assertIsDisplayed().performClick() - compose.runOnIdle { assertTrue(state.value.seriesList.single().isVisible); open.value = false } - compose.waitForIdle() - compose.runOnIdle { open.value = true } - compose.onNodeWithText("Legend").performClick() - compose.onNodeWithText("None").performClick() - compose.runOnIdle { assertEquals(3, state.value.legendPosition) } - } - - @Test fun axisDialogUsesPcIntegerBoundsAndRejectsInvalidInputWithoutDismissing() { - var saved: Pair? = null - compose.setContent { - GraphYAxisDialog("A", 10.9, 99.9, false, {}, { min,max,_ -> saved = min to max }) - } - compose.onNodeWithText("10").assertExists() - compose.onNodeWithText("99").assertExists() - compose.onAllNodes(hasSetTextAction())[1].performTextReplacement("NaN") - compose.onNodeWithText("OK").performClick() - compose.onNodeWithText("Please input the correct min/max value.").assertIsDisplayed() - compose.runOnIdle { assertNull(saved) } - compose.onAllNodes(hasSetTextAction())[1].performTextReplacement("99") - compose.onNodeWithText("OK").performClick() - compose.runOnIdle { assertEquals(10.0 to 99.0, saved) } - } - - @Test fun outOfRangeCurvesAreClippedInsteadOfFlattenedOntoAxesForBothGraphTypes() { - val state = mutableStateOf(snapshot()) - val multi = mutableStateOf(false) - compose.setContent { - CompositionLocalProvider(LocalDensity provides Density(1f)) { - Box(Modifier.size(400.dp, 240.dp).testTag("graph")) { - MultiXGraphCanvas(state.value, isMultiAxis = multi.value) - } - } - } - fun redPixels(): Int { - val pixels = compose.onNodeWithTag("graph").captureToImage().toPixelMap() - var count = 0 - for (y in 12..216) for (x in 70..380) { - val c = pixels[x,y] - if (c.red > .8f && c.green < .3f && c.blue < .3f) count++ - } - return count - } - for (isMulti in listOf(false,true)) { - compose.runOnIdle { multi.value = isMulti; state.value = snapshot() } - assertEquals(0, redPixels()) - compose.runOnIdle { state.value = snapshot().copy(seriesList = listOf(signal().copy( - points = listOf(1000.0 to 0.0, 1001.0 to 0.0)))) } - assertTrue(redPixels() > 0) // A real on-axis signal must remain visible. - } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt deleted file mode 100644 index 3b3e3a2..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.Text -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import com.dct.app.presentation.main.MainUiState -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 MeasurementElapsedTimeUiTest { - @get:Rule val compose = createComposeRule() - - @Test fun timerIsLeftOfPacketsAndTicksWithoutNewPacketsThenClearsAndRestarts() { - val ui = mutableStateOf(MainUiState(isMeasuring = true, measurementStartedAtElapsedMillis = 1_000L)) - val clock = AtomicLong(6_000L) - compose.setContent { - Row(verticalAlignment = Alignment.CenterVertically) { - MeasurementElapsedTime(ui.value, nowMillis = clock::get) - Text("Packets: 0") - } - } - val timer = compose.onNodeWithTag("MeasurementElapsedTime").assertIsDisplayed() - .assertTextEquals("재생 00:00:05").fetchSemanticsNode().boundsInRoot - val packets = compose.onNodeWithText("Packets: 0").assertIsDisplayed().fetchSemanticsNode().boundsInRoot - assertTrue(timer.right <= packets.left) - assertEquals(timer.center.y, packets.center.y, 2f) - clock.set(8_500L) - compose.mainClock.advanceTimeBy(1_100) - compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:07") - // The label must reset as soon as measuring ends, even before ViewModel cleanup clears the start. - compose.runOnIdle { ui.value = ui.value.copy(isMeasuring = false) } - compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:00") - clock.set(20_000L) - compose.mainClock.advanceTimeBy(1_100) - compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:00") - clock.set(22_000L) - compose.runOnIdle { ui.value = ui.value.copy(isMeasuring = true, - measurementStartedAtElapsedMillis = 20_000L) } - compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:02") - } - - @Test fun noSuccessfulStartShowsZero() { - compose.setContent { MeasurementElapsedTime(MainUiState(), nowMillis = { 99_000L }) } - compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:00") - } - - @Test fun backgroundStopsUiTicksAndResumeIncludesTimeAsleep() { - 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) { - MeasurementElapsedTime(MainUiState(isMeasuring = true, measurementStartedAtElapsedMillis = 1_000L), - nowMillis = clock::get) - } - } - compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:05") - compose.runOnIdle { owner.registry.currentState = Lifecycle.State.CREATED } - clock.set(30_000L) - compose.mainClock.advanceTimeBy(1_100) - compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:05") - compose.runOnIdle { owner.registry.currentState = Lifecycle.State.RESUMED } - compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:29") - } -} 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 deleted file mode 100644 index 71de104..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt +++ /dev/null @@ -1,181 +0,0 @@ -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 -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 -> - (0 until image.height).count { y -> - val c = pixels[x, y] - c.red > 0.65f && c.green < 0.35f && c.blue < 0.35f - } - } - } - - @Test fun startUsesRedFileBadgeAndStopUsesDarkSquare() { - compose.setContent { - Row(Modifier.background(Color.White)) { - RecordingRibbonButton("기록 시작", enabled = true, onClick = {}) - RecordingRibbonButton("기록 중지", enabled = true, isStop = true, onClick = {}) - } - } - val start = compose.onNodeWithContentDescription("기록 시작").assertIsEnabled().assertHasClickAction() - val stop = compose.onNodeWithContentDescription("기록 중지").assertIsEnabled().assertHasClickAction() - val density = compose.density.density - // A visible 12dp disc has ~113dp² area; the previous 8dp badge had only ~50dp². - assertTrue("Record dot must retain its enlarged area", redPixels(start.captureToImage()) > 90 * density * density) - assertEquals(0, redPixels(stop.captureToImage())) - val image = stop.captureToImage() - val pixels = image.toPixelMap() - // The square occupies the lower-right corner of the centered 24dp file icon. - val cx = (image.width / 2f + 5f * density).toInt() - val cy = (image.height / 2f + 5f * density).toInt() - for (dx in listOf(-4, 0, 4)) for (dy in listOf(-4, 0, 4)) { - val c = pixels[cx + (dx * density).toInt(), cy + (dy * density).toInt()] - assertTrue("Stop badge must be a filled square", c.red < 0.4f && c.green < 0.4f && c.blue < 0.4f) - } - } - - @Test fun disabledButtonsHaveNoRedBadgeAndCannotInvokeActions() { - var clicks = 0 - compose.setContent { - Row(Modifier.background(Color.White)) { - RecordingRibbonButton("기록 시작", enabled = false, onClick = { clicks++ }) - RecordingRibbonButton("기록 중지", enabled = false, isStop = true, onClick = { clicks++ }) - } - } - for (label in listOf("기록 시작", "기록 중지")) { - val button = compose.onNodeWithContentDescription(label).assertIsNotEnabled() - assertEquals(0, redPixels(button.captureToImage())) - button.performClick() - } - compose.runOnIdle { assertEquals(0, clicks) } - } - - @Test fun recordingStateSwitchKeepsSeparateActionsAndDescriptionsAtLargeFont() { - val recording = mutableStateOf(false) - var starts = 0 - var stops = 0 - compose.setContent { - CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 1.3f)) { - Row(Modifier.background(Color.White)) { - RecordingRibbonButton("신호 기록 시작", enabled = !recording.value, - onClick = { starts++; recording.value = true }) - RecordingRibbonButton("신호 기록 중지", enabled = recording.value, isStop = true, - onClick = { stops++; recording.value = false }) - } - } - } - compose.onNodeWithContentDescription("신호 기록 시작").assertIsDisplayed().assertIsEnabled().performClick() - compose.onNodeWithContentDescription("신호 기록 시작").assertIsNotEnabled() - compose.onNodeWithContentDescription("신호 기록 중지").assertIsDisplayed().assertIsEnabled().performClick() - compose.onNodeWithContentDescription("신호 기록 시작").assertIsEnabled() - compose.onNodeWithContentDescription("신호 기록 중지").assertIsNotEnabled() - compose.runOnIdle { assertEquals(1, starts); assertEquals(1, stops) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt deleted file mode 100644 index 1d891ef..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BarChart -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -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 RibbonButtonUiTest { - @get:Rule val compose = createComposeRule() - - @Test fun allThreeLabelsRemainClickableAndScrollableWithLargerFont() { - val labels = listOf("Graph", "Alphanumerical", "MultiAxisGraph") - val clicks = mutableListOf() - compose.setContent { - val density = LocalDensity.current.density - CompositionLocalProvider(LocalDensity provides Density(density, 1.3f)) { - Row(Modifier.width(220.dp).height(44.dp).horizontalScroll(rememberScrollState())) { - labels.forEach { label -> - RibbonButton(Icons.Default.BarChart, label, label = label, - onClick = { clicks += label }) - } - } - } - } - labels.forEach { label -> - compose.onNodeWithText(label).performScrollTo().assertIsDisplayed().assertHasClickAction().performClick() - } - compose.runOnIdle { assertEquals(labels, clicks) } - } - - @Test fun disabledLabelCannotRunActionAndIconOnlyToolsStayUnchanged() { - val enabled = mutableStateOf(false) - var clicks = 0 - compose.setContent { - Row { - RibbonButton(Icons.Default.BarChart, "New Graph Window", label = "Graph", - enabled = enabled.value, onClick = { clicks++ }) - RibbonButton(Icons.Default.BarChart, "Save", onClick = { clicks++ }) - } - } - compose.onNodeWithText("Graph").assertIsNotEnabled().performClick() - compose.runOnIdle { assertEquals(0, clicks); enabled.value = true } - compose.onNodeWithText("Graph").assertIsEnabled().performClick() - compose.onNodeWithContentDescription("Save").assertHasClickAction().performClick() - compose.onNodeWithText("Save").assertDoesNotExist() - compose.runOnIdle { assertEquals(2, clicks) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenuUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenuUiTest.kt deleted file mode 100644 index 60b19ea..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenuUiTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.toPixelMap -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.ViewRootForTest -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import com.dct.app.domain.model.chart.InterpolationMode -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test - -@OptIn(InternalTestApi::class) -class RibbonSettingsMenuUiTest { - @get:Rule val compose = createComposeRule() - private val expanded = mutableStateOf(false) - private val formatsEnabled = mutableStateOf(true) - private val mode = mutableStateOf(InterpolationMode.LINEAR) - private val csv = mutableStateOf(true) - private val mf4 = mutableStateOf(false) - private val autostart = mutableStateOf(false) - private val csvMilliseconds = mutableStateOf(false) - private var formatChanges = 0 - - private fun showMenu() { - compose.setContent { - val density = LocalDensity.current.density - CompositionLocalProvider(LocalDensity provides Density(density, 1.3f)) { - // A real parent popup away from (0, 0) catches nested-window coordinate errors. - Box(Modifier.offset(x = 40.dp, y = 80.dp)) { - Button(onClick = { expanded.value = true }) { Text("메뉴") } - RibbonFileMenu(expanded.value, onDismissRequest = { expanded.value = false }) { - RibbonSettingsMenuCategories( - menuExpanded = expanded.value, - graphMode = mode.value, - recordingFormatsEnabled = formatsEnabled.value, - writeCsv = csv.value, writeMf4 = mf4.value, - autostartRecording = autostart.value, recordingFolder = "Data", - onGraphModeChange = { mode.value = it }, - onRecordingFormatsChange = { c, m -> csv.value = c; mf4.value = m; formatChanges++ }, - onAutostartRecordingChange = { autostart.value = it }, - csvWithMilliseconds = csvMilliseconds.value, - onCsvWithMillisecondsChange = { csvMilliseconds.value = it }, - onDismissMenu = { expanded.value = false } - ) - } - } - } - } - compose.onNodeWithText("메뉴").performClick() - } - - private fun screenBounds(node: SemanticsNodeInteraction): Rect { - val semantics = node.fetchSemanticsNode() - val view = (semantics.root as ViewRootForTest).view - var bounds = Rect.Zero - compose.runOnIdle { - val location = IntArray(2) - view.getLocationOnScreen(location) - bounds = semantics.boundsInRoot.translate(Offset(location[0].toFloat(), location[1].toFloat())) - } - return bounds - } - - @Test fun allSubmenusAlignWithTheirCategoryInScreenCoordinates() { - showMenu() - listOf("Graph Type" to "GRAPH", "Measurement Recording" to "RECORDING", "계측과 함께 측정" to "AUTOSTART", "CSV 밀리초" to "CSV_MILLISECONDS") - .forEach { (label, category) -> - compose.onNodeWithText(label).performClick() - val row = screenBounds(compose.onNodeWithTag("RibbonSettingsCategory_$category")) - val submenu = screenBounds(compose.onNodeWithTag("RibbonSettingsSubmenu")) - assertEquals("$label right edge", row.right, submenu.left, 1f) - assertEquals("$label top edge", row.top, submenu.top, 1f) - } - } - - @Test fun bothMenuSurfacesHaveAnOpaqueGrayOutline() { - showMenu() - compose.onNodeWithText("Graph Type").performClick() - listOf("RibbonFileMenu", "RibbonSettingsSubmenu").forEach { tag -> - val pixels = compose.onNodeWithTag(tag).captureToImage().toPixelMap() - val border = pixels[0, pixels.height / 2] - assertEquals("$tag red", RibbonMenuBorderColor.red, border.red, 0.04f) - assertEquals("$tag green", RibbonMenuBorderColor.green, border.green, 0.04f) - assertEquals("$tag blue", RibbonMenuBorderColor.blue, border.blue, 0.04f) - assertEquals("$tag opacity", 1f, border.alpha, 0.01f) - } - } - - @Test fun csvMillisecondsDefaultsFalseAndCanSwitchBothWaysWhileMeasuring() { - formatsEnabled.value = false - showMenu() - compose.onNodeWithText("True").assertDoesNotExist() - compose.onNodeWithText("False").assertDoesNotExist() - compose.onNodeWithText("마이크로초").assertDoesNotExist() - compose.onNodeWithText("CSV 밀리초").assertIsEnabled().performClick() - compose.onNodeWithTag("CsvMilliseconds_false", useUnmergedTree = true).assertIsSelected() - compose.onNodeWithTag("CsvMilliseconds_true", useUnmergedTree = true).assertIsNotSelected() - compose.onNodeWithText("True").performClick() - compose.runOnIdle { assertEquals(true, csvMilliseconds.value) } - compose.onNodeWithText("CSV 밀리초").assertDoesNotExist() - compose.onNodeWithText("메뉴").performClick() - compose.onNodeWithText("CSV 밀리초").performClick() - compose.onNodeWithText("False").performClick() - compose.runOnIdle { assertEquals(false, csvMilliseconds.value) } - } - - @Test fun rootContainsOnlyCategoriesAndGraphSelectionClosesBothMenus() { - showMenu() - listOf("Graph Type", "Measurement Recording", "계측과 함께 측정").forEach { - compose.onNodeWithText(it).assertIsDisplayed() - } - listOf("Linear", "Pulse", "Write CSV", "Write MF4", "On", "Off").forEach { - compose.onNodeWithText(it).assertDoesNotExist() - } - compose.onNodeWithText("Graph Type").performClick() - compose.onNodeWithText("Pulse").assertIsDisplayed().performClick() - compose.runOnIdle { assertEquals(InterpolationMode.PWM_STEP, mode.value) } - compose.onNodeWithText("Graph Type").assertDoesNotExist() - compose.onNodeWithText("메뉴").performClick() - compose.onNodeWithText("Pulse").assertDoesNotExist() - } - - @Test fun recordingCheckboxesRemainOpenForMultipleSelections() { - showMenu() - compose.onNodeWithText("Measurement Recording").performClick() - compose.onNodeWithText("Write MF4").performClick() - compose.onNodeWithText("Write CSV").assertIsDisplayed().performClick() - compose.runOnIdle { - assertEquals(false, csv.value); assertEquals(true, mf4.value); assertEquals(2, formatChanges) - } - compose.onNodeWithText("Folder: Data").assertIsDisplayed() - } - - @Test fun recordingIsVisiblyDisabledAndCannotOpenWhileMeasuring() { - formatsEnabled.value = false - showMenu() - compose.onNodeWithText("Measurement Recording").assertIsNotEnabled().performClick() - compose.onNodeWithText("Write CSV").assertDoesNotExist() - compose.onNodeWithText("계측 중 변경 불가").assertDoesNotExist() - val layouts = mutableListOf() - compose.onNodeWithText("Measurement Recording", useUnmergedTree = true) - .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } - assertEquals(ColorTextSecondary.copy(alpha = 0.5f), layouts.single().layoutInput.style.color) - compose.runOnIdle { assertEquals(0, formatChanges) } - } - - @Test fun measurementStartingClosesAlreadyOpenRecordingSubmenu() { - showMenu() - compose.onNodeWithText("Measurement Recording").performClick() - compose.onNodeWithText("Write CSV").assertIsDisplayed() - compose.runOnIdle { formatsEnabled.value = false } - compose.onNodeWithText("Write CSV").assertDoesNotExist() - compose.onNodeWithText("Measurement Recording").assertIsNotEnabled() - compose.runOnIdle { formatsEnabled.value = true } - compose.onNodeWithText("Write CSV").assertDoesNotExist() - } - - @Test fun switchingCategoryReplacesSubmenuAndAutostartSelectionClosesMenu() { - showMenu() - compose.onNodeWithText("Graph Type").performClick() - compose.onNodeWithText("Linear").assertIsDisplayed() - compose.onNodeWithText("계측과 함께 측정").performClick() - compose.onNodeWithText("Linear").assertDoesNotExist() - compose.onNodeWithText("On").assertIsDisplayed().performClick() - compose.runOnIdle { assertEquals(true, autostart.value) } - compose.onNodeWithText("계측과 함께 측정").assertDoesNotExist() - } -} 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 deleted file mode 100644 index c3e74c6..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiConnectionRibbonUiTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -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/androidTest/java/com/dct/app/presentation/monitor/components/WifiManualConnectionUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiManualConnectionUiTest.kt deleted file mode 100644 index e5b6a24..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiManualConnectionUiTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.unit.dp -import androidx.test.platform.app.InstrumentationRegistry -import com.dct.app.data.communication.* -import com.dct.app.presentation.dialog.WifiManualConnectionForm -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class WifiManualConnectionUiTest { - @get:Rule val compose = createComposeRule() - - @Test fun inputsStartEmptyAndPresetButtonsAreAbsent() { - compose.setContent { - MaterialTheme { Box(Modifier.size(480.dp, 500.dp)) { - WifiManualConnectionForm(WifiConnectionInput(), {}, emptyList(), {}, { _, _ -> }) - } } - } - for (tag in listOf("wifi:manual-ip", "wifi:manual-port")) { - compose.onNodeWithTag(tag).assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(""))) - } - compose.onNodeWithTag("wifi:manual-connect").assertIsNotEnabled() - compose.onNodeWithText("SoftAP (192.168.4.1)").assertDoesNotExist() - compose.onNodeWithText("Local (127.0.0.1)").assertDoesNotExist() - } - - @Test fun latestInputRestoresAfterDismissWithoutConnectingAndClearingPersists() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val prefs = context.getSharedPreferences("wifi_ui_test_${System.nanoTime()}", android.content.Context.MODE_PRIVATE) - val visible = mutableStateOf(true) - try { - compose.setContent { - if (visible.value) MaterialTheme { - val store = remember { WifiConnectionPreferences(prefs) } - var input by remember { mutableStateOf(store.load()) } - Box(Modifier.size(480.dp, 500.dp)) { - WifiManualConnectionForm(input, { input = it; store.save(it) }, emptyList(), {}, { _, _ -> fail("No connection expected") }) - } - } - } - compose.onNodeWithTag("wifi:manual-ip").performTextReplacement("192.168.10.8") - compose.onNodeWithTag("wifi:manual-port").performTextReplacement("7777") - compose.runOnIdle { visible.value = false } - compose.waitForIdle() - compose.runOnIdle { visible.value = true } - compose.onNodeWithTag("wifi:manual-ip").assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("192.168.10.8"))) - compose.onNodeWithTag("wifi:manual-port").assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("7777"))) - compose.onNodeWithTag("wifi:manual-port").performTextClearance() - compose.runOnIdle { assertEquals("", WifiConnectionPreferences(prefs).load().port) } - } finally { prefs.edit().clear().commit() } - } - - @Test fun discoveredPcOnlyAppliesOnTapAndConnectionUsesExplicitPort() { - val input = mutableStateOf(WifiConnectionInput()) - var endpoint: Pair? = null - compose.setContent { - MaterialTheme { Box(Modifier.size(480.dp, 500.dp)) { - WifiManualConnectionForm(input.value, { input.value = it }, - listOf(DiscoveredLanServer("Test PC", "192.168.10.8", 7777)), {}, { ip, port -> endpoint = ip to port }) - } } - } - compose.runOnIdle { assertEquals(WifiConnectionInput(), input.value) } - compose.onNodeWithText("클릭하여 IP와 포트 적용").performClick() - compose.onNodeWithTag("wifi:manual-connect").performScrollTo().performClick() - compose.runOnIdle { assertEquals("192.168.10.8" to 7777, endpoint) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowActivationTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowActivationTest.kt deleted file mode 100644 index 9119c74..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowActivationTest.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalTestApi::class) -class WindowActivationTest { - @get:Rule val compose = createComposeRule() - - @Test fun childButtonRaisesBackWindowOnlyOnSingleFingerReleaseAndStillClicks() { - var activations = 0 - var clicks = 0 - val backZ = mutableStateOf(0f) - compose.setContent { - MaterialTheme { - Box(Modifier.size(300.dp)) { - Box(Modifier.size(240.dp).zIndex(backZ.value) - .background(Color.White) - .bringWindowToFrontOnPress("back") { activations++; backZ.value = 2f }) { - Button(onClick = { clicks++ }, modifier = Modifier.size(80.dp).testTag("button")) { - Text("Button") - } - } - Box(Modifier.offset(x = 100.dp, y = 50.dp).size(160.dp) - .zIndex(1f).background(Color.Blue) - .bringWindowToFrontOnPress("front") {}) - } - } - } - compose.onNodeWithTag("button").performTouchInput { down(center) } - compose.runOnIdle { - assertEquals(0, activations) - assertEquals(0f, backZ.value) - assertEquals(0, clicks) - } - compose.onNodeWithTag("button").performTouchInput { up() } - compose.runOnIdle { assertEquals(1, clicks); assertEquals(1, activations); assertEquals(2f, backZ.value) } - } - - @Test fun scrollingWindowBodyStillScrollsAndActivatesOnlyOnce() { - var activations = 0 - lateinit var scroll: androidx.compose.foundation.ScrollState - compose.setContent { - scroll = rememberScrollState() - Box(Modifier.size(240.dp).bringWindowToFrontOnPress("window") { activations++ }) { - Column(Modifier.fillMaxSize().verticalScroll(scroll).testTag("scroll")) { - Spacer(Modifier.height(2000.dp).fillMaxWidth()) - } - } - } - compose.onNodeWithTag("scroll").performTouchInput { swipeUp() } - compose.runOnIdle { - assertEquals(1, activations) - assertTrue(scroll.value > 0) - } - } - - @Test fun textFieldCanReceiveFocusAndInputAfterWindowActivation() { - var activations = 0 - val value = mutableStateOf("") - compose.setContent { - Box(Modifier.size(240.dp).bringWindowToFrontOnPress("window") { activations++ }) { - BasicTextField(value.value, onValueChange = { value.value = it }, - modifier = Modifier.size(180.dp, 50.dp).testTag("input")) - } - } - compose.onNodeWithTag("input").performTouchInput { click() } - compose.onNodeWithTag("input").assertIsFocused().performTextInput("RPM") - compose.runOnIdle { assertEquals(1, activations); assertEquals("RPM", value.value) } - } - - @Test fun hoveringDoesNotRaiseAWindow() { - var activations = 0 - compose.setContent { - Box(Modifier.size(200.dp).testTag("window") - .bringWindowToFrontOnPress("window") { activations++ }) - } - compose.onNodeWithTag("window").performMouseInput { enter(center); moveTo(center); exit() } - compose.runOnIdle { assertEquals(0, activations) } - } - - @Test fun fingersOnDifferentWindowsNeverChangeZOrderEvenWithoutMovement() { - val raised = mutableStateOf("") - compose.setContent { - val navigation = remember { WorkspaceTouchNavigation() } - val x = remember { ScrollState(0) } - val y = remember { ScrollState(0) } - CompositionLocalProvider(LocalWorkspaceTouchNavigation provides navigation) { - Box(Modifier.size(300.dp, 200.dp).testTag("workspace") - .workspaceTouchNavigation(navigation, x, y)) { - Box(Modifier.size(140.dp, 200.dp).zIndex(if (raised.value == "back") 3f else 0f) - .testTag("back").bringWindowToFrontOnPress("back") { raised.value = "back" }) - Box(Modifier.offset(x = 160.dp).size(140.dp, 200.dp) - .zIndex(if (raised.value == "front") 3f else 1f) - .bringWindowToFrontOnPress("front") { raised.value = "front" }) - } - } - } - compose.onNodeWithTag("workspace").performTouchInput { - down(0, Offset(width * .2f, height * .5f)) - } - compose.runOnIdle { assertEquals("", raised.value) } - compose.onNodeWithTag("workspace").performTouchInput { - down(1, Offset(width * .8f, height * .5f)) - up(0) - } - compose.runOnIdle { assertEquals("", raised.value) } - compose.onNodeWithTag("workspace").performTouchInput { up(1) } - compose.runOnIdle { assertEquals("", raised.value) } - compose.onNodeWithTag("back").performTouchInput { advanceEventTime(500); click() } - compose.runOnIdle { assertEquals("back", raised.value) } - } - - @Test fun mousePressStillActivatesImmediatelyWithoutWaitingForRelease() { - var activations = 0 - compose.setContent { - Box(Modifier.size(200.dp).testTag("window") - .bringWindowToFrontOnPress("window") { activations++ }) - } - compose.onNodeWithTag("window").performMouseInput { moveTo(center); press() } - compose.runOnIdle { assertEquals(1, activations) } - compose.onNodeWithTag("window").performMouseInput { release() } - compose.runOnIdle { assertEquals(1, activations) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowParityUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowParityUiTest.kt deleted file mode 100644 index 9e9866b..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowParityUiTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.dp -import com.dct.app.domain.model.chart.* -import com.dct.app.presentation.signaltree.* -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class WindowParityUiTest { - @get:Rule val compose = createComposeRule() - - @Test fun actualGraphContentForwardsTapToCursorCallback() { - var cursor: Double? = null - compose.setContent { - MaterialTheme { - Box(Modifier.size(600.dp, 300.dp).testTag("graph")) { - GraphWindowContent( - snapshot = ChartRenderSnapshot(0.0, 10.0, emptyList(), emptyMap(), legendPosition = 3), - windowId = "test", onRemoveSignal = {}, onClearAll = {}, - onCrosshairTimeChange = { cursor = it } - ) - } - } - } - compose.onNodeWithTag("graph").performTouchInput { click(center) } - compose.runOnIdle { assertNotNull(cursor); assertTrue(cursor!! in 0.0..10.0) } - } - - @Test fun headerDividerDragResizesWithoutActivatingSort() { - val width = mutableStateOf(160.dp) - var sorts = 0 - compose.setContent { - Box(Modifier.size(400.dp, 60.dp)) { - ResizableTableHeaderCell("Name", width.value, {}, Modifier.testTag("header"), - onWidthChange = { width.value = it }, onClick = { sorts++ }) - } - } - compose.onNodeWithTag("header").performTouchInput { - swipe(Offset(right - 3f, centerY), Offset(right + 70f, centerY), 300) - } - compose.runOnIdle { assertTrue(width.value > 160.dp); assertEquals(0, sorts) } - } - - @Test fun windowPreferencesAreIsolatedAndRestoredAfterDisposal() { - var saved = emptyMap() - val visible = mutableStateOf(true) - lateinit var search: MutableState - lateinit var expanded: MutableState - lateinit var checked: MutableState> - compose.setContent { - if (visible.value) { - WindowUiStateProvider("one", saved, { saved = it }) { - search = rememberWindowString("search") - expanded = rememberWindowBoolean("expanded") - checked = rememberWindowChecked("checked") - Text(search.value, Modifier.testTag("value")) - } - } - } - compose.runOnIdle { search.value = "RPM"; expanded.value = true; checked.value = setOf(1L, 2L) } - compose.runOnIdle { visible.value = false } - compose.waitForIdle() - compose.runOnIdle { visible.value = true } - compose.onNodeWithTag("value").assertTextEquals("RPM") - compose.runOnIdle { assertTrue(expanded.value); assertEquals(setOf(1L, 2L), checked.value) } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt deleted file mode 100644 index 43b1d0e..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt +++ /dev/null @@ -1,534 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.dp -import com.dct.app.domain.model.chart.ChartRenderSnapshot -import com.dct.app.presentation.chart.MultiXGraphCanvas -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class WorkspaceTouchNavigationUiTest { - @get:Rule val compose = createComposeRule() - private lateinit var x: ScrollState - private lateinit var y: ScrollState - private var zooms = 0 - private var pans = 0 - private var taps = 0 - private var drags = 0 - private var activations = 0 - private lateinit var innerScroll: ScrollState - private var movingWindowBounds = Rect(-200f, -200f, -100f, -100f) - - private fun content( - graph: Boolean = false, - multi: Boolean = false, - header: Boolean = false, - table: Boolean = false, - reserveWindowSurface: Boolean = false, - movingWindowSurface: Boolean = false, - canvasEnabled: Boolean = true - ) { - compose.setContent { - x = remember { ScrollState(if (canvasEnabled) 150 else 0) } - y = remember { ScrollState(if (canvasEnabled) 150 else 0) } - innerScroll = remember { ScrollState(10) } - val navigation = remember { WorkspaceTouchNavigation() } - val horizontalFling = ScrollableDefaults.flingBehavior() - val verticalFling = ScrollableDefaults.flingBehavior() - CompositionLocalProvider(LocalWorkspaceTouchNavigation provides navigation) { - if (movingWindowSurface) { - DisposableEffect(navigation) { - navigation.registerWindowTouchRegion("moving-window") { movingWindowBounds } - onDispose { navigation.unregisterWindowTouchRegion("moving-window") } - } - } - if (reserveWindowSurface) { - // Mirrors MdiSubWindow's persistent root-bounds - // registration. The synthetic window starts at the - // viewport origin and is larger than the viewport. - DisposableEffect(navigation) { - navigation.registerWindowTouchRegion("test-window") { - Rect(0f, 0f, 900f, 700f) - } - onDispose { navigation.unregisterWindowTouchRegion("test-window") } - } - } - Box(Modifier.size(400.dp, 300.dp).testTag("viewport") - .workspaceCanvasScroll(canvasEnabled, navigation, x, y, horizontalFling, verticalFling)) { - val windowModifier = Modifier - .size(900.dp, 700.dp) - .then(if (reserveWindowSurface) Modifier.windowTouchScrollBarrier("test-window") else Modifier) - Box(windowModifier.testTag("window").bringWindowToFrontOnPress("window") { activations++ }) { - if (graph) { - MultiXGraphCanvas(ChartRenderSnapshot(0.0, 60.0, emptyList(), emptyMap(), legendPosition = 3), - Modifier.size(900.dp, 700.dp), isMultiAxis = multi, - onPanTime = { pans++ }, onZoomTime = { _, _ -> zooms++ }, - onCrosshairTimeChange = { taps++ }) - } else if (table) { - val stopNested = remember { object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource) = available - } } - Box(Modifier.size(900.dp, 700.dp).nestedScroll(stopNested).verticalScroll(innerScroll)) { - Box(Modifier.size(900.dp, 1400.dp).clickable { taps++ }) - } - } else if (header) { - Box(Modifier.size(900.dp, 700.dp).pointerInput(Unit) { - detectDragGestures(onDragStart = { navigation.claimSinglePointerDrag() }) { change, _ -> - change.consume(); drags++ - } - }) - } else { - Box(Modifier.size(900.dp, 700.dp).clickable { taps++ }) - } - } - } - } - } - } - - @Test fun emptyCanvasClampsContentAndPreservesTapWithSmallFingerMovement() { - content(canvasEnabled = false) - val viewport = compose.onNodeWithTag("viewport").fetchSemanticsNode().boundsInRoot - val content = compose.onNodeWithTag("window").fetchSemanticsNode().boundsInRoot - assertEquals(viewport.width, content.width, 0.1f) - assertEquals(viewport.height, content.height, 0.1f) - compose.onNodeWithTag("viewport").performTouchInput { - down(center) - moveTo(center + Offset(3f, 2f), delayMillis = 180) - up() - } - compose.runOnIdle { - assertEquals(1, taps) - assertEquals(0, x.value) - assertEquals(0, y.value) - } - } - - @Test fun emptyCanvasDoesNotPanWithOneOrTwoFingers() { - content(canvasEnabled = false) - compose.onNodeWithTag("viewport").performTouchInput { - down(center) - moveTo(center - Offset(100f, 80f), delayMillis = 80) - up() - } - compose.onNodeWithTag("viewport").performTouchInput { - val first = center - Offset(50f, 0f) - val second = center + Offset(50f, 0f) - down(0, first) - down(1, second) - for (i in 1..5) { - updatePointerTo(0, first - Offset(i * 15f, i * 10f)) - updatePointerTo(1, second - Offset(i * 15f, i * 10f)) - move() - } - up(1) - moveBy(0, Offset(-60f, -60f)) - up(0) - } - compose.runOnIdle { - assertEquals(0, x.value) - assertEquals(0, y.value) - assertEquals(0, pans) - assertEquals(0, zooms) - } - } - - private fun slowSingleFingerDrag() { - compose.onNodeWithTag("viewport").performTouchInput { - val start = center - down(start) - // Real fingers cross touch slop over many MOVE events, unlike - // the old tests' first jump of 60-140 px. - for (i in 1..60) moveTo(start - Offset(0f, i * 2f), delayMillis = 8) - up() - } - } - - @Test fun slowHeaderDragCrossesSlopWithoutBeingCancelled() { - content(header = true, reserveWindowSurface = true) - slowSingleFingerDrag() - compose.runOnIdle { - assertTrue("Slow title drag must reach the window handler", drags > 0) - assertEquals(150, x.value); assertEquals(150, y.value) - } - } - - @Test fun slowInnerScrollCrossesSlopWithoutBeingCancelled() { - content(table = true, reserveWindowSurface = true) - slowSingleFingerDrag() - compose.runOnIdle { - assertTrue("Slow drag must scroll the inner list", innerScroll.value > 10) - assertEquals(150, x.value); assertEquals(150, y.value) - } - } - - private fun translateWithOneFingerTail() { - compose.onNodeWithTag("viewport").performTouchInput { - val a = center - Offset(50f, 0f) - val b = center + Offset(50f, 0f) - down(0, a); down(1, b) - for (i in 1..5) { - updatePointerTo(0, a - Offset(i * 15f, i * 10f)) - updatePointerTo(1, b - Offset(i * 15f, i * 10f)) - move() - } - up(1) - } - val afterX = x.value - val afterY = y.value - compose.onNodeWithTag("viewport").performTouchInput { - moveBy(0, Offset(-60f, -60f)); up(0) - } - compose.runOnIdle { - assertEquals(afterX, x.value); assertEquals(afterY, y.value) - assertTrue(x.value > 150); assertTrue(y.value > 150) - assertEquals(0, taps); assertEquals(0, pans); assertEquals(0, zooms) - assertEquals(0, activations) - } - } - - @Test fun twoFingerTranslationOverClickableContentScrollsBothAxesWithoutClickOrTail() { - content() - translateWithOneFingerTail() - } - @Test fun ordinaryGraphTranslationMovesCanvasNotTimeAxisOrScale() { - content(graph = true) - translateWithOneFingerTail() - } - @Test fun multiAxisGraphTranslationMovesCanvasNotTimeAxisOrScale() { - content(graph = true, multi = true) - translateWithOneFingerTail() - } - - private fun translateAfterFirstFingerStartsScrolling() { - val start = compose.onNodeWithTag("viewport").fetchSemanticsNode().boundsInRoot.size - val a = Offset(start.width * .5f, start.height * .6f) - val drift = Offset(-45f, -45f) - compose.onNodeWithTag("viewport").performTouchInput { - down(0, a) - // Fingers rarely land in the same event. A scroll can already have - // consumed movement when the second finger reaches the screen. - moveTo(0, a + drift, delayMillis = 32) - } - val beforeX = x.value - val beforeY = y.value - val beforePans = pans - val beforeInner = innerScroll.value - val beforeActivations = activations - compose.onNodeWithTag("viewport").performTouchInput { - val b = a + drift + Offset(80f, 0f) - down(1, b) - for (i in 1..6) { - val delta = Offset(-i * 15f, -i * 10f) - updatePointerTo(0, a + drift + delta) - updatePointerTo(1, b + delta) - move() - } - advanceEventTime(300) - up(1); up(0) - } - compose.runOnIdle { - assertTrue("Second finger must take over horizontal canvas scroll", x.value > beforeX) - assertTrue("Second finger must take over vertical canvas scroll", y.value > beforeY) - assertEquals("Time axis must stop moving after the second finger lands", beforePans, pans) - assertEquals("Table must stop scrolling after the second finger lands", beforeInner, innerScroll.value) - assertEquals(0, zooms) - assertEquals(0, taps) - assertEquals("Two-finger takeover must not activate another window", beforeActivations, activations) - } - } - - @Test fun delayedSecondFingerOverGraphTakesOverCanvasScroll() { - content(graph = true) - translateAfterFirstFingerStartsScrolling() - } - - @Test fun delayedSecondFingerOverTableTakesOverCanvasScroll() { - content(table = true) - translateAfterFirstFingerStartsScrolling() - } - - @Test fun delayedSecondFingerOverReservedTableTakesOverCanvasScroll() { - content(table = true, reserveWindowSurface = true) - translateAfterFirstFingerStartsScrolling() - } - - @Test fun delayedSecondFingerOverCanvasTakesOverBothScrollAxes() { - content() - translateAfterFirstFingerStartsScrolling() - } - - @Test fun delayedSecondFingerOverMultiAxisGraphTakesOverCanvasScroll() { - content(graph = true, multi = true) - translateAfterFirstFingerStartsScrolling() - } - - @Test fun canvasPanContinuesWhenWindowMovesAcrossOriginalDownPoint() { - content(movingWindowSurface = true) - compose.onNodeWithTag("viewport").performTouchInput { - down(center) - for (i in 1..20) moveBy(Offset(-3f, -2f), delayMillis = 16) - } - compose.runOnIdle { - assertEquals(210, x.value) - assertEquals(190, y.value) - // Model a neighbouring window moving under the original DOWN point. - movingWindowBounds = Rect(0f, 0f, 10000f, 10000f) - } - compose.onNodeWithTag("viewport").performTouchInput { - for (i in 1..20) moveBy(Offset(-3f, -2f), delayMillis = 16) - } - compose.runOnIdle { - assertEquals(270, x.value) - assertEquals(230, y.value) - } - compose.onNodeWithTag("viewport").performTouchInput { up() } - } - - @Test fun oneFingerCanvasTracksExactDistanceBeforeRelease() { - content() - compose.onNodeWithTag("viewport").performTouchInput { - down(center) - for (i in 1..40) moveBy(Offset(-3f, -2f), delayMillis = 16) - } - compose.runOnIdle { - assertEquals("Horizontal movement must be applied once", 270, x.value) - assertEquals("Vertical movement must be applied once", 230, y.value) - } - compose.onNodeWithTag("viewport").performTouchInput { - for (i in 1..20) moveBy(Offset(3f, 2f), delayMillis = 16) - } - compose.runOnIdle { - assertEquals(210, x.value) - assertEquals(190, y.value) - } - compose.onNodeWithTag("viewport").performTouchInput { up() } - } - - @Test fun oneFingerDiagonalDragMovesWorkspaceOnBothAxes() { - content() - compose.onNodeWithTag("viewport").performTouchInput { - val start = center - down(0, start) - for (i in 1..8) { - updatePointerTo(0, start - Offset(i * 18f, i * 14f)) - move() - } - up(0) - } - compose.runOnIdle { - assertTrue("Diagonal drag must move the horizontal canvas", x.value > 150) - assertTrue("Diagonal drag must move the vertical canvas", y.value > 150) - assertEquals(0, taps) - } - } - - @Test fun nestedWindowScrollDoesNotMoveWorkspaceCanvas() { - content(table = true, reserveWindowSurface = true) - compose.onNodeWithTag("viewport").performTouchInput { - down(0, center) - moveTo(0, center - Offset(0f, 140f), delayMillis = 16) - moveTo(0, center - Offset(0f, 220f), delayMillis = 16) - up(0) - } - compose.runOnIdle { - assertEquals("Window scroll must not move horizontal canvas", 150, x.value) - assertEquals("Window scroll must not move vertical canvas", 150, y.value) - assertTrue("The nested window list should consume the vertical drag", innerScroll.value > 10) - } - } - - @Test fun anyGestureStartingInsideWindowDoesNotFallBackToCanvas() { - content(reserveWindowSurface = true) - compose.onNodeWithTag("viewport").performTouchInput { - down(0, center) - moveTo(0, center - Offset(140f, 100f), delayMillis = 16) - up(0) - } - compose.runOnIdle { - assertEquals(150, x.value) - assertEquals(150, y.value) - assertEquals(0, taps) - } - } - - @Test fun oneFingerDiagonalDragKeepsGraphPlotGestureOnTimeAxis() { - content(graph = true) - compose.onNodeWithTag("viewport").performTouchInput { - val start = center - down(0, start) - for (i in 1..8) { - updatePointerTo(0, start - Offset(i * 18f, i * 14f)) - move() - } - up(0) - } - compose.runOnIdle { - assertTrue("Graph plot drag must pan the time axis", pans > 0) - assertEquals("Graph plot drag must not move the workspace vertically", 150, y.value) - } - } - @Test fun twoFingersOverHeaderPanCanvasWithoutActivatingWindow() { - content(header = true, reserveWindowSurface = true) - translateWithOneFingerTail() - compose.runOnIdle { assertEquals(0, drags) } - } - @Test fun twoFingersOverReservedTablePanCanvasWithoutMovingInnerTable() { - content(table = true, reserveWindowSurface = true) - translateWithOneFingerTail() - compose.runOnIdle { - assertEquals("A two-finger page pan must not scroll the inner table", 10, innerScroll.value) - } - } - @Test fun tableNestedScrollBarrierDoesNotBlockTwoFingerCanvasNavigation() { - content(table = true) - translateWithOneFingerTail() - compose.runOnIdle { assertEquals(10, innerScroll.value) } - } - @Test fun canvasFlingStopsAtTopLeftBoundary() { - content() - compose.onNodeWithTag("viewport").performTouchInput { - val a = Offset(30f, 30f) - val b = Offset(90f, 30f) - down(0, a); down(1, b) - for (i in 1..12) { - updatePointerTo(0, a + Offset(i * 20f, i * 20f)) - updatePointerTo(1, b + Offset(i * 20f, i * 20f)) - move() - } - up(0); up(1) - } - compose.runOnIdle { assertEquals(0, x.value); assertEquals(0, y.value) } - compose.mainClock.advanceTimeBy(1000) - compose.runOnIdle { assertEquals(0, x.value); assertEquals(0, y.value) } - } - @Test fun thirdFingerEndsNavigationUntilAllContactsLift() { - content() - compose.onNodeWithTag("viewport").performTouchInput { - down(0, center - Offset(50f, 0f)); down(1, center + Offset(50f, 0f)) - down(2, center + Offset(0f, 60f)); up(2) - updatePointerTo(0, center - Offset(100f, 50f)) - updatePointerTo(1, center - Offset(0f, 50f)) - move(); up(1); up(0) - } - compose.runOnIdle { assertEquals(150, x.value); assertEquals(150, y.value); assertEquals(0, taps); assertEquals(0, activations) } - } - @Test fun pinchOverGraphChangesScaleWithoutMovingCanvasOrClicking() { - content(graph = true) - compose.onNodeWithTag("viewport").performTouchInput { - down(0, center - Offset(30f, 0f)); down(1, center + Offset(30f, 0f)) - for (i in 1..5) { - updatePointerTo(0, center - Offset(30f + i * 12f, 0f)) - updatePointerTo(1, center + Offset(30f + i * 12f, 0f)) - move() - } - up(0); up(1) - } - compose.runOnIdle { - assertTrue(zooms > 0); assertEquals(0, pans); assertEquals(0, taps) - assertEquals(0, activations) - assertEquals(150, x.value); assertEquals(150, y.value) - } - } - @Test fun establishedWindowDragIsNotCancelledBySecondFinger() { - content(header = true, reserveWindowSurface = true) - compose.onNodeWithTag("viewport").performTouchInput { - down(0, center) - moveTo(0, center + Offset(60f, 0f)) - down(1, center - Offset(50f, 0f)) - updatePointerTo(0, center + Offset(100f, 0f)) - updatePointerTo(1, center - Offset(10f, 0f)) - move(); up(1); up(0) - } - compose.runOnIdle { - assertTrue(drags > 0); assertEquals(150, x.value); assertEquals(150, y.value) - assertEquals(1, activations) - } - } - - private fun fastTwoFingerSwipe(releaseJitter: Boolean = false) { - compose.onNodeWithTag("viewport").performTouchInput { - val a = center - Offset(50f, 0f) - val b = center + Offset(50f, 0f) - down(0, a); down(1, b) - for (i in 1..8) { - updatePointerTo(0, a - Offset(i * 12f, i * 6f)) - updatePointerTo(1, b - Offset(i * 12f, i * 6f)) - move(delayMillis = 10) - } - up(0) - if (releaseJitter) { - // Real touch hardware can emit small MOVE events between the two UPs. - moveBy(1, Offset(-1f, -1f), delayMillis = 16) - } - up(1) - } - } - - @Test fun twoFingerReleaseFlingsBothAxesWithoutActivatingWindow() { - content() - compose.mainClock.autoAdvance = false - fastTwoFingerSwipe() - val releasedX = x.value - val releasedY = y.value - compose.mainClock.advanceTimeBy(200) - compose.runOnIdle { - assertTrue(x.value > releasedX); assertTrue(y.value > releasedY) - assertEquals(0, activations); assertEquals(0, taps) - } - compose.mainClock.advanceTimeBy(5000) - compose.runOnIdle { assertFalse(x.isScrollInProgress); assertFalse(y.isScrollInProgress) } - } - - @Test fun smallMovementBetweenFingerReleasesPreservesInertia() { - content() - compose.mainClock.autoAdvance = false - fastTwoFingerSwipe(releaseJitter = true) - val releasedX = x.value - val releasedY = y.value - compose.mainClock.advanceTimeBy(200) - compose.runOnIdle { - assertTrue("Horizontal inertia was cancelled by release jitter", x.value > releasedX) - assertTrue("Vertical inertia was cancelled by release jitter", y.value > releasedY) - assertEquals(0, activations) - assertEquals(0, taps) - } - compose.mainClock.advanceTimeBy(5000) - } - - @Test fun newTouchOverWindowStopsTwoFingerInertiaImmediately() { - content() - compose.mainClock.autoAdvance = false - fastTwoFingerSwipe() - compose.mainClock.advanceTimeBy(80) - compose.onNodeWithTag("viewport").performTouchInput { down(center) } - val stoppedX = x.value - val stoppedY = y.value - compose.mainClock.advanceTimeBy(200) - compose.runOnIdle { - assertEquals(stoppedX, x.value); assertEquals(stoppedY, y.value) - assertEquals(0, activations) - } - compose.onNodeWithTag("viewport").performTouchInput { up() } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt deleted file mode 100644 index 1a15c4b..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.dct.app.presentation.signaltree - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ProvideTextStyle -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.dct.app.core.theme.DctTheme -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.MdbDatabase -import com.dct.app.domain.model.mdb.SignalDefinition -import com.dct.app.domain.model.mdb.SignalGroup -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import kotlin.math.abs - -class SignalTableTextAlignmentUiTest { - @get:Rule val compose = createComposeRule() - - @Test fun mdbAtTabletScale() = assertAlignment("mdb", 1.3f) - @Test fun canAtTabletScale() = assertAlignment("can", 1.3f) - @Test fun a2lAtTabletScale() = assertAlignment("a2l", 1.3f) - @Test fun mdbAtLargeScale() = assertAlignment("mdb", 2f) - @Test fun canAtLargeScale() = assertAlignment("can", 2f) - @Test fun a2lAtLargeScale() = assertAlignment("a2l", 2f) - - private fun assertAlignment(kind: String, scale: Float) { - val signal = SignalDefinition( - reclid = 1, idx = 2, name = "RPM_CENTER", attr = MdbAttrType.ATTR_2_UINT16, - cvsA = 1.0, cvsB = 0.0, unit = "rpm", color = 0xFF0000, - min = 321.0, max = 999.0, description = "센터 gy 신호", messageName = "Engine" - ) - var density = 1f - compose.setContent { - density = LocalDensity.current.density - CompositionLocalProvider(LocalDensity provides Density(density, scale)) { - DctTheme { - // Reproduce the large inherited line height that caused the original regression. - ProvideTextStyle(TextStyle(fontSize = 16.sp, lineHeight = 24.sp)) { - // Keep horizontal width clipping separate from this vertical alignment test. - WindowUiStateProvider(kind, mapOf("expanded" to "true", "width:name" to "200.0", - "width:unit" to "100.0", "width:desc" to "300.0", "width:min" to "120.0"), {}) { - Box(Modifier.size(1200.dp, 200.dp)) { - when (kind) { - "mdb" -> WindowsClassicMdbSignalTableWindow( - MdbDatabase(groups = listOf(SignalGroup(1, "", "", true, signals = listOf(signal)))), emptyMap() - ) - "can" -> WindowsClassicCanSignalTableWindow(listOf(signal), emptyMap()) - else -> WindowsClassicA2lSignalTableWindow(listOf(signal), emptyMap()) - } - } - } - } - } - } - } - val row = compose.onNode(hasClickAction() and hasText(signal.name)).fetchSemanticsNode().boundsInRoot - val expectedRow = with(Density(density, scale)) { signalTableRowHeight(this).toPx() } - assertEquals(expectedRow, row.height, 1f) - for ((text, style) in listOf( - signal.name to SignalTableTextStyle, signal.unit to SignalTableTextStyle, - signal.description to SignalTableDetailTextStyle, "321.000" to SignalTableDetailTextStyle - )) { - val node = compose.onNodeWithText(text, useUnmergedTree = true) - val layouts = mutableListOf() - node.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(layouts)) } - val layout = layouts.single() - assertEquals(style.lineHeight, layout.layoutInput.style.lineHeight) - assertFalse("Clipped text: $kind/$scale/$text size=${layout.size} paragraph=${layout.multiParagraph.width}x${layout.multiParagraph.height} ellipsized=${layout.isLineEllipsized(0)}", layout.didOverflowHeight) - val bounds = node.fetchSemanticsNode().boundsInRoot - assertTrue("Not vertically centered: $kind/$scale/$text", abs(bounds.center.y - row.center.y) <= 1f) - assertTrue("No vertical breathing room: $kind/$scale/$text", bounds.height < row.height) - } - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt deleted file mode 100644 index 69fcbd6..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.dct.app.presentation.signaltree - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import com.dct.app.core.theme.DctTheme -import com.dct.app.data.parser.A2lParser.A2lCharacteristic -import com.dct.app.data.parser.A2lParser.A2lCharacteristicArray -import com.dct.app.domain.model.formula.FormulaDefinition -import com.dct.app.domain.model.mdb.* -import com.dct.app.presentation.monitor.components.FormulaWindowContent -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -class TableAutoFitUiTest { - @get:Rule val compose = createComposeRule() - private val name = "WMWM_온도_Shift_iiiiMMMM_WMWM" - private val description = "Description_온도_WMWM_아주긴설명" - - @Test fun mdbManualWidthAndTinyClipping() = assertWindow("mdb") - @Test fun canManualWidthAndTinyClipping() = assertWindow("can") - @Test fun a2lManualWidthAndTinyClipping() = assertWindow("a2l") - @Test fun formulaManualWidthAndTinyClipping() = assertWindow("formula") - @Test fun constantManualWidthAndTinyClipping() = assertWindow("constant") - @Test fun compensationManualWidthAndTinyClipping() = assertWindow("compensation") - @Test fun characteristicCheckboxIsIncludedInFit() = assertWindow("characteristic") - @Test fun characteristicArrayManualWidthAndTinyClipping() = assertWindow("array") - - private fun assertWindow(kind: String) { - val signal = SignalDefinition(1, 2, name, MdbAttrType.ATTR_2_UINT16, - 1.0, 0.0, "rpm", 0xFF0000, 0.0, 999.0, description = description) - val key = if (kind == "compensation") "test:YNAME" else "name" - lateinit var widths: WindowColumnWidths - val visible = mutableStateOf(true) - var saved = mapOf("width:$key" to "130.0") - var pixelsPerDp = 1f - compose.setContent { - val current = LocalDensity.current - pixelsPerDp = current.density - CompositionLocalProvider(LocalDensity provides Density(current.density, 1.3f)) { - DctTheme { - if (visible.value) WindowUiStateProvider(kind, saved, { saved = it }) { - widths = rememberWindowColumnWidths() - Box(Modifier.size(1200.dp, 450.dp)) { - when (kind) { - "mdb" -> WindowsClassicMdbSignalTableWindow( - MdbDatabase(groups = listOf(SignalGroup(1, "", "", true, signals = listOf(signal)))), emptyMap()) - "can" -> WindowsClassicCanSignalTableWindow(listOf(signal), emptyMap()) - "a2l" -> WindowsClassicA2lSignalTableWindow(listOf(signal), emptyMap()) - "formula" -> FormulaWindowContent(listOf(FormulaDefinition(id = "f", name = name, - expression = "1", unit = "rpm")), onAddFormula = {}, onUpdateFormula = {}, onDeleteFormula = {}) - "constant" -> WindowsClassicConstantTableWindow(listOf(MdbConstant(1, name, 1.0, - 0x1000, 0.0, 100.0, MdbAttrType.ATTR_2_UINT16, description))) - "characteristic" -> WindowsClassicA2lCharacteristicTableWindow(listOf(A2lCharacteristic(name, description))) - "array" -> WindowsClassicA2lCharacteristicArrayTableWindow(listOf(A2lCharacteristicArray(name, description))) - else -> CompensationTable(compensationMapColumns(true), listOf(CompensationMap( - CompensationMapType.TYPE_A, 1, name, 0, MdbAttrType.ATTR_2_UINT16, 0.0, 100.0, - "X", 0, MdbAttrType.ATTR_2_UINT16, 1.0, 0.0, description - )), "test", selectedIndex = 0, onRowClick = { _, _ -> }, onDescriptionChange = { _, _ -> }) - } - } - } - } - } - } - // Legacy saved widths must not imply that the column is already auto-fitted. - compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(0) - compose.onAllNodesWithContentDescription("열 너비 열기")[0].performClick() - val fitted = widths[key]!! - assertTrue(fitted > 130.dp) - assertTextFits(name) - val title = when (kind) { "mdb" -> "Signal name"; "can" -> "DBC name"; "compensation" -> "YNAME"; else -> "Name" } - assertTextFits(title) - compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(1) - - val icon = compose.onNodeWithContentDescription("열 너비 닫기").fetchSemanticsNode().boundsInRoot - val root = compose.onRoot().fetchSemanticsNode().boundsInRoot - val grip = Offset(icon.right + 6f * pixelsPerDp, icon.center.y) - root.topLeft - compose.onRoot().performTouchInput { swipe(grip, grip - Offset(30f * pixelsPerDp, 0f), 300) } - assertTrue("Grip drag did not resize $kind", widths[key]!! < fitted) - compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(0) - compose.onAllNodesWithContentDescription("열 너비 열기")[0].performClick() - assertEquals(fitted, widths[key]) - assertTextFits(name) - - // A tiny manual reduction clears fit mode. Opening must recover, not reset to default. - compose.runOnIdle { widths[key] = fitted - 2.dp } - compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(0) - compose.onAllNodesWithContentDescription("열 너비 열기")[0].performClick() - assertEquals(fitted, widths[key]) - assertTextFits(name) - - compose.runOnIdle { visible.value = false } - compose.waitForIdle() - compose.runOnIdle { visible.value = true } - compose.onAllNodesWithContentDescription("열 너비 닫기").assertCountEquals(1) - compose.onNodeWithContentDescription("열 너비 닫기").performClick() - assertFalse(saved.containsKey("width:$key")) - assertFalse(saved.containsKey("autoFit:$key")) - - // Exercise the shared description path, including nested padding in calibration tables. - if (kind != "formula") { - val descriptionIndex = if (kind == "compensation") 2 else 1 - compose.onAllNodesWithContentDescription("열 너비 열기")[descriptionIndex].performScrollTo().performClick() - assertTextFits(description) - compose.onNodeWithContentDescription("열 너비 닫기").performClick() - } - } - - private fun assertTextFits(text: String) { - val layouts = mutableListOf() - compose.onNodeWithText(text, useUnmergedTree = true) - .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(layouts)) } - val layout = layouts.single() - val details = "$text size=${layout.size} paragraph=${layout.multiParagraph.width}x${layout.multiParagraph.height} " + - "constraints=${layout.layoutInput.constraints} style=${layout.layoutInput.style}" - // A naturally sized Text can have a paragraph as wide as its parent's constraint. - // didOverflowWidth compares those different boxes, not whether glyphs are clipped. - assertTrue("Clipped line: $details", layout.getLineRight(0) <= layout.layoutInput.constraints.maxWidth + 1f) - assertFalse("Hidden lines: $details", layout.multiParagraph.didExceedMaxLines) - assertFalse("Ellipsized: $details", layout.isLineEllipsized(0)) - } - - @Test fun packetOnlyRecompositionDoesNotRemeasureMetadataButScaleAndNamesDo() { - val packet = mutableStateOf(0) - val scale = mutableStateOf(1.3f) - val items = mutableStateOf(listOf(name)) - var extractions = 0 - var measured = 0.dp - compose.setContent { - val density = LocalDensity.current - CompositionLocalProvider(LocalDensity provides Density(density.density, scale.value)) { - measured = rememberAutoFitWidth("Name", items.value, 110.dp) { extractions++; it } - Text(packet.value.toString()) - } - } - val initial = extractions - val initialWidth = measured - repeat(5) { compose.runOnIdle { packet.value++ }; compose.waitForIdle() } - assertEquals(initial, extractions) - compose.runOnIdle { items.value = listOf(name + name) } - compose.waitForIdle() - assertTrue(extractions > initial) - assertTrue(measured > initialWidth) - val beforeScale = extractions - val beforeWidth = measured - compose.runOnIdle { scale.value = 2f } - compose.waitForIdle() - assertTrue(extractions > beforeScale) - assertTrue(measured > beforeWidth) - } -} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt deleted file mode 100644 index e23db17..0000000 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.dct.app.presentation.signaltree - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import com.dct.app.core.theme.DctTheme -import com.dct.app.data.parser.A2lParser.A2lCharacteristic -import com.dct.app.data.parser.A2lParser.A2lCharacteristicArray -import com.dct.app.domain.model.mdb.* -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test - -/** 원문을 자르지 않고 실제 TextLayout의 말줄임과 열 확장 후 복원을 확인한다. */ -class TableTextOverflowUiTest { - @get:Rule val compose = createComposeRule() - private val longUnit = "UNIT_NAME_TOO_LONG" - private val longMinimum = "-123456.789" - - @Test fun mdbNarrowCellsEllipsizeAndWideningRestoresFullText() = checkWindow("mdb") - @Test fun canNarrowCellsEllipsizeAndWideningRestoresFullText() = checkWindow("can") - @Test fun a2lNarrowCellsEllipsizeAndWideningRestoresFullText() = checkWindow("a2l") - @Test fun characteristicLimitsAndAddressEllipsize() = checkWindow("characteristic") - @Test fun arrayLimitsAddressAndMatrixValuesEllipsize() = checkWindow("array") - - private fun checkWindow(kind: String) { - lateinit var widths: WindowColumnWidths - val signal = SignalDefinition(1, 2, "Signal", MdbAttrType.ATTR_2_UINT16, - 1.0, 0.0, longUnit, 0xFF0000, -123456.789, 654321.987) - compose.setContent { - val density = LocalDensity.current - CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { - DctTheme { - WindowUiStateProvider(kind, mapOf("expanded" to "true", "width:unit" to "40.0", - "width:min" to "40.0", "width:max" to "40.0", "width:addr" to "40.0"), {}) { - widths = rememberWindowColumnWidths() - Box(Modifier.size(1100.dp, 450.dp)) { - when (kind) { - "mdb" -> WindowsClassicMdbSignalTableWindow( - MdbDatabase(groups = listOf(SignalGroup(1, "", "", true, signals = listOf(signal)))), emptyMap()) - "can" -> WindowsClassicCanSignalTableWindow(listOf(signal), emptyMap()) - "a2l" -> WindowsClassicA2lSignalTableWindow(listOf(signal), emptyMap()) - "characteristic" -> WindowsClassicA2lCharacteristicTableWindow(listOf( - A2lCharacteristic("Scalar", lowerLimit = -123456.789, upperLimit = 654321.987, - ecuAddress = 0x12345678))) - else -> WindowsClassicA2lCharacteristicArrayTableWindow(listOf( - A2lCharacteristicArray("Array", lowerLimit = -123456.789, upperLimit = 654321.987, - ecuAddress = 0x12345678, matrixDim = listOf(1), values = listOf(123456789.12)))) - } - } - } - } - } - } - assertEllipsized(longMinimum, true) - if (kind in setOf("mdb", "can", "a2l")) assertEllipsized(longUnit, true) - else assertEllipsized("0x12345678", true) - if (kind == "array") assertEllipsized("123456789.12", true) - - compose.runOnIdle { - widths["min"] = 220.dp - widths["unit"] = 220.dp - widths["addr"] = 220.dp - } - compose.waitForIdle() - assertEllipsized(longMinimum, false) - if (kind in setOf("mdb", "can", "a2l")) assertEllipsized(longUnit, false) - else assertEllipsized("0x12345678", false) - } - - private fun assertEllipsized(original: String, expected: Boolean) { - val results = mutableListOf() - compose.onNodeWithText(original, useUnmergedTree = true) - .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(results)) } - val layout = results.single() - assertEquals(original, layout.layoutInput.text.text) - assertEquals(TextOverflow.Ellipsis, layout.layoutInput.overflow) - assertFalse(layout.layoutInput.softWrap) - assertEquals("Unexpected truncation for $original", expected, layout.isLineEllipsized(0)) - } -} 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 deleted file mode 100644 index bc2655e..0000000 --- a/Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt +++ /dev/null @@ -1,291 +0,0 @@ -package com.dct.app.data.maintenance - -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.repository.PacketRepositoryImpl -import com.dct.app.domain.model.protocol.DctCommand -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.domain.repository.PacketRepository -import io.mockk.* -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -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 { - - @Test - fun `removeFirmware는 슬롯 인덱스 검증과 0x06 삭제 응답을 정확히 처리한다`() = runTest { - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) - - // 슬롯 1 삭제 성공 (0x06, 0x01) - coEvery { repo.sendCommand(match { it.payload[0] == 0x06.toByte() && it.payload[1] == 1.toByte() }) } returns - DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x06, 0x01))) - - val service = MaintenanceService(repo) - val success = service.removeFirmware(1) - assertTrue(success.isSuccess) - - // 단말 삭제 거절 (0x06, 0x02) - coEvery { repo.sendCommand(match { it.payload[0] == 0x06.toByte() && it.payload[1] == 2.toByte() }) } returns - DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x06, 0x02))) - val failed = service.removeFirmware(2) - assertTrue(failed.isFailure) - - // 슬롯 범위 초과 - assertTrue(service.removeFirmware(-1).isFailure) - assertTrue(service.removeFirmware(256).isFailure) - - // 비READY 상태에서 요청 시 거절 - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.MEASURING) - assertTrue(service.removeFirmware(1).isFailure) - } - - @Test - fun `triggerFirmwareUpdate는 0x07 발송 후 0x08 진행 및 완료 패킷을 수신하고 상태별 ACK를 회신한다`() = runTest { - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) - val stream = MutableSharedFlow(replay = 10, extraBufferCapacity = 16) - every { repo.packetStream } returns stream - - val sentCommands = mutableListOf() - coEvery { repo.sendCommand(capture(sentCommands)) } returns - DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x07, 0x01))) - - val service = MaintenanceService(repo) - val job = async { service.triggerFirmwareUpdate(0) } - - // 단말의 0x08 진행 보고 방출 (30%, 75%) - stream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x01, 30))) - stream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x01, 75))) - // 단말의 0x08 완료 보고 방출 - stream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x02))) - - val result = job.await() - assertTrue(result.isSuccess) - - // 0x07 요청, 0x08 ACK(0x01), 0x08 ACK(0x01), 0x08 ACK(0x02) 전송 확인 - val fwInfoCommands = sentCommands.filter { it.command == DctCommand.CMD_FW_INFO.code } - assertTrue(fwInfoCommands.any { it.payload[0] == 0x07.toByte() }) - assertTrue(fwInfoCommands.any { it.payload[0] == 0x08.toByte() && it.payload[1] == 0x01.toByte() }) - assertTrue(fwInfoCommands.any { it.payload[0] == 0x08.toByte() && it.payload[1] == 0x02.toByte() }) - } - - @ParameterizedTest - @ValueSource(booleans = [false, true]) - fun `재생 전 무응답은 종료하고 재생 요청 이후에는 실제 링크 단절까지 연결을 유지한다`(measuring: Boolean) = runTest { - val wifi = mockk(relaxed = true) - val packets = MutableSharedFlow(extraBufferCapacity = 16) - val isConnected = MutableStateFlow(true) - every { wifi.isConnected } returns isConnected - 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 - - val testDispatcher = StandardTestDispatcher(testScheduler) - val repository = PacketRepositoryImpl( - wifiSocketManager = wifi, - ioDispatcher = testDispatcher, - heartbeatDispatcher = testDispatcher, - heartbeatIntervalMs = 1000L, - heartbeatTimeoutMs = 5000L, - enableHeartbeat = true, - monotonicTimeMillis = { testScheduler.currentTime } - ) - - // 핸드셰이크 통과 설정 - coEvery { wifi.sendCommand(match { it.command == 0x00.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x80.toByte(), payload = byteArrayOf(1))) - true - } - coEvery { wifi.sendCommand(match { it.command == 0x01.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x81.toByte(), payload = byteArrayOf(4, 68, 67, 84, 0))) - true - } - coEvery { wifi.sendCommand(match { it.command == 0x09.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x89.toByte(), payload = byteArrayOf(1))) - true - } - coEvery { wifi.sendCommand(match { it.command == 0x03.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x83.toByte(), payload = ByteArray(6))) - true - } - coEvery { wifi.sendCommand(match { it.command == 0x04.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x84.toByte(), payload = byteArrayOf(1))) - true - } - - 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) - - // 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() } - - // 종료 뒤 늦게 도착한 START ACK로 연결/계측을 되살리지 않는다. - packets.emit(DctPacket( - command = DctCommand.RES_START_TRANSMIT_DATA.code, - payload = byteArrayOf(1, 1), - transportSessionId = 1L - )) - advanceTimeBy(6001L) - assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value) - assertFalse(isConnected.value) - assertEquals(1, sentList.count { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code }) - } - - @ParameterizedTest - @ValueSource(booleans = [false, true]) - fun `계측 데이터 또는 Heartbeat 응답이 있으면 연결을 유지한다`(heartbeatReply: Boolean) = runTest { - val wifi = mockk(relaxed = true) - val packets = MutableSharedFlow(extraBufferCapacity = 64) - val isConnected = MutableStateFlow(true) - every { wifi.isConnected } returns isConnected - every { wifi.packetStream } returns packets - every { wifi.currentSessionId } returns 2L - every { wifi.offerCommand(any()) } returns true - - val sentList = mutableListOf() - coEvery { wifi.sendCommand(capture(sentList)) } returns true - - val testDispatcher = StandardTestDispatcher(testScheduler) - val repository = PacketRepositoryImpl( - wifiSocketManager = wifi, - ioDispatcher = testDispatcher, - heartbeatDispatcher = testDispatcher, - heartbeatIntervalMs = 2000L, - heartbeatTimeoutMs = 5000L, - enableHeartbeat = true, - monotonicTimeMillis = { testScheduler.currentTime } - ) - - // 핸드셰이크 통과 - coEvery { wifi.sendCommand(match { it.command == 0x00.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x80.toByte(), payload = byteArrayOf(1))) - true - } - coEvery { wifi.sendCommand(match { it.command == 0x01.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x81.toByte(), payload = byteArrayOf(4, 68, 67, 84, 0))) - true - } - coEvery { wifi.sendCommand(match { it.command == 0x09.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x89.toByte(), payload = byteArrayOf(1))) - true - } - coEvery { wifi.sendCommand(match { it.command == 0x03.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x83.toByte(), payload = ByteArray(6))) - true - } - coEvery { wifi.sendCommand(match { it.command == 0x04.toByte() }) } coAnswers { - packets.emit(DctPacket(command = 0x84.toByte(), payload = byteArrayOf(1))) - true - } - coEvery { wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) } coAnswers { - packets.emit(DctPacket(command = DctCommand.RES_START_TRANSMIT_DATA.code, payload = byteArrayOf(1, 1))) - true - } - - assertTrue(repository.connect("") is DctResult.Success) - assertTrue(repository.startStreaming(30) is DctResult.Success) - assertEquals(DeviceConnectionState.MEASURING, repository.connectionState.value) - - // 계측 중 대량의 CAN 텔레메트리 스트림 수신 (A3 패킷들) - repeat(5) { - packets.emit(DctPacket( - command = DctCommand.SEND_CAN_FRAME.code, - payload = byteArrayOf(0, 8, 1, 2, 3, 4, 5, 6, 7, 8), - transportSessionId = 2L - )) - } - - // Heartbeat 주기(2000ms) 전진 - advanceTimeBy(2001L) - - // 정상 데이터만 수신하는 경우와 0x85 응답만 받는 경우를 각각 검증한다. - packets.emit(DctPacket( - 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/data/protocol/DctOtaProtocolTest.kt b/Android/app/src/test/java/com/dct/app/data/protocol/DctOtaProtocolTest.kt deleted file mode 100644 index 366d5f2..0000000 --- a/Android/app/src/test/java/com/dct/app/data/protocol/DctOtaProtocolTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.dct.app.data.protocol - -import com.dct.app.domain.model.protocol.DctCommand -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -@DisplayName("DctProtocolRequests OTA 및 Alive Check 바이트 규격 검증") -class DctOtaProtocolTest { - private val codec = DctPacketCodec() - - @Test - fun `buildRemoveFirmwareRequest는 0x31 서브타입 0x06의 유효한 바이너리 프레임을 생성한다`() { - val frame = DctProtocolRequests.buildRemoveFirmwareRequest(3) - // Header: 0xA1, Length: 0x00 0x03, Cmd: 0x31, Payload: 0x06 0x03, Checksum, Footer: 0x7E - assertThat(frame.size).isEqualTo(8) - assertThat(frame[0]).isEqualTo(0xA1.toByte()) - assertThat(frame[1]).isEqualTo(0x00.toByte()) - assertThat(frame[2]).isEqualTo(0x03.toByte()) - assertThat(frame[3]).isEqualTo(0x31.toByte()) - assertThat(frame[4]).isEqualTo(0x06.toByte()) - assertThat(frame[5]).isEqualTo(0x03.toByte()) - assertThat(frame[7]).isEqualTo(0x7E.toByte()) - - val decoded = codec.decode(frame) - assertThat(decoded).isNotNull() - assertThat(decoded!!.command).isEqualTo(DctCommand.CMD_FW_INFO.code) - assertThat(decoded.payload).isEqualTo(byteArrayOf(0x06, 0x03)) - } - - @Test - fun `buildTriggerFirmwareUpdateRequest는 0x31 서브타입 0x07의 유효한 바이너리 프레임을 생성한다`() { - val frame = DctProtocolRequests.buildTriggerFirmwareUpdateRequest() - assertThat(frame.size).isEqualTo(8) - assertThat(frame[0]).isEqualTo(0xA1.toByte()) - assertThat(frame[1]).isEqualTo(0x00.toByte()) - assertThat(frame[2]).isEqualTo(0x03.toByte()) - assertThat(frame[3]).isEqualTo(0x31.toByte()) - assertThat(frame[4]).isEqualTo(0x07.toByte()) - assertThat(frame[5]).isEqualTo(0x00.toByte()) - assertThat(frame[7]).isEqualTo(0x7E.toByte()) - - val decoded = codec.decode(frame) - assertThat(decoded).isNotNull() - assertThat(decoded!!.command).isEqualTo(DctCommand.CMD_FW_INFO.code) - assertThat(decoded.payload).isEqualTo(byteArrayOf(0x07, 0x00)) - } - - @Test - fun `firmwareUpdateResponse는 0x31 서브타입 0x08 상태 ACK 패킷을 생성한다`() { - val progressAck = DctProtocolRequests.firmwareUpdateResponse(0x01) - assertThat(progressAck.command).isEqualTo(0x31.toByte()) - assertThat(progressAck.payload).isEqualTo(byteArrayOf(0x08, 0x01)) - - val completeAck = DctProtocolRequests.firmwareUpdateResponse(0x02) - assertThat(completeAck.command).isEqualTo(0x31.toByte()) - assertThat(completeAck.payload).isEqualTo(byteArrayOf(0x08, 0x02)) - - assertThrows(IllegalArgumentException::class.java) { - DctProtocolRequests.firmwareUpdateResponse(256) - } - } - - @Test - fun `possibleToConnect는 0x05 커맨드 패킷을 생성한다`() { - val packet = DctProtocolRequests.possibleToConnect() - assertThat(packet.command).isEqualTo(DctCommand.CMD_POSSIBLE_TO_CONNECT.code) - assertThat(packet.payload).isEmpty() - } - - @Test - fun `슬롯 범위 초과 시 예외가 발생한다`() { - assertThrows(IllegalArgumentException::class.java) { - DctProtocolRequests.buildRemoveFirmwareRequest(-1) - } - assertThrows(IllegalArgumentException::class.java) { - DctProtocolRequests.buildRemoveFirmwareRequest(256) - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/presentation/maintenance/MaintenanceUiParityTest.kt b/Android/app/src/test/java/com/dct/app/presentation/maintenance/MaintenanceUiParityTest.kt deleted file mode 100644 index 6c657aa..0000000 --- a/Android/app/src/test/java/com/dct/app/presentation/maintenance/MaintenanceUiParityTest.kt +++ /dev/null @@ -1,292 +0,0 @@ -package com.dct.app.presentation.maintenance - -import com.dct.app.core.common.DctResult -import com.dct.app.data.maintenance.DeviceMaintenanceProtocol -import com.dct.app.data.maintenance.MaintenanceService -import com.dct.app.data.protocol.DctProtocolRequests -import com.dct.app.domain.model.maintenance.RemoteLogFile -import com.dct.app.domain.model.protocol.DctCommand -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.domain.repository.PacketRepository -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -/** - * M3 UI/UX & Maintenance Dialog Parity 단위 테스트. - * - * 1. [ISSUE-R3-01] WorkspaceRibbonBar 유지보수 활성화 및 표준 DID 7종 정의/비트마스크 사양 - * 2. [ISSUE-R3-02] UDS DID 개별/전체 읽기/쓰기 및 차종/DB 제어 프로토콜 - * 3. [ISSUE-R3-03] 단말 로그 삭제(0x26), 전체 선택, 내림차순(rbegin) 삭제 순서 - */ -class MaintenanceUiParityTest { - - @Test - fun testStandardDidDefinitionsParity() { - // PC CDIDInformationDlg.cpp 와 1:1 일치하는 표준 7종 DID 정의 검증 - assertEquals(7, STANDARD_DIDS.size) - - val f187 = STANDARD_DIDS.find { it.did == 0xF187 } - assertNotNull(f187) - assertEquals("Part Number", f187!!.name) - assertEquals(0x40, f187.bitmask) - assertEquals(10, f187.maxChars) - - val f1a1 = STANDARD_DIDS.find { it.did == 0xF1A1 } - assertNotNull(f1a1) - assertEquals("Supplier Code", f1a1!!.name) - assertEquals(0x20, f1a1.bitmask) - assertEquals(4, f1a1.maxChars) - - val f18b = STANDARD_DIDS.find { it.did == 0xF18B } - assertNotNull(f18b) - assertEquals("Product Date", f18b!!.name) - assertEquals(0x10, f18b.bitmask) - assertEquals(8, f18b.maxChars) - - val f197 = STANDARD_DIDS.find { it.did == 0xF197 } - assertNotNull(f197) - assertEquals("System Name", f197!!.name) - assertEquals(0x08, f197.bitmask) - assertEquals(4, f197.maxChars) - - val f191 = STANDARD_DIDS.find { it.did == 0xF191 } - assertNotNull(f191) - assertEquals("HW Version", f191!!.name) - assertEquals(0x04, f191.bitmask) - assertEquals(4, f191.maxChars) - - val f193 = STANDARD_DIDS.find { it.did == 0xF193 } - assertNotNull(f193) - assertEquals("Supplier HW Version", f193!!.name) - assertEquals(0x02, f193.bitmask) - assertEquals(4, f193.maxChars) - - val f100 = STANDARD_DIDS.find { it.did == 0xF100 } - assertNotNull(f100) - assertEquals("Part Name 2 / SW Version", f100!!.name) - assertEquals(0x01, f100.bitmask) - assertEquals(25, f100.maxChars) - - // 전체 선택 시 비트마스크 합: 0x40 | 0x20 | 0x10 | 0x08 | 0x04 | 0x02 | 0x01 = 0x7F (127) - val fullMask = STANDARD_DIDS.fold(0) { acc, def -> acc or def.bitmask } - assertEquals(0x7F, fullMask) - - // 부분 선택 마스크 조합 계산 검증 (F187 + F100 = 0x41) - val partialMask = f187.bitmask or f100.bitmask - assertEquals(0x41, partialMask) - } - - @Test - fun testDidProtocolRequestsAndResponses() = runTest { - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) - - // 1. 개별 DID 읽기 (0x15 -> 0x95) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_REQ_DID_INFO.code }) - } returns DctResult.Success( - DctPacket.create( - DctCommand.RES_REQ_DID_INFO, - byteArrayOf(0xF1.toByte(), 0x87.toByte(), 0x01, 0x04, 'D'.code.toByte(), 'C'.code.toByte(), 'T'.code.toByte(), '1'.code.toByte()) - ) - ) - - val service = MaintenanceService(repo) - val readBytes = service.readDid(0xF187) - assertEquals("DCT1", readBytes.toString(Charsets.US_ASCII)) - - // 2. 개별 DID 쓰기 (0x16 -> 0x96) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_DID_INFO.code }) - } returns DctResult.Success( - DctPacket.create( - DctCommand.RES_DID_INFO, - byteArrayOf(0xF1.toByte(), 0x87.toByte(), 0x01, 0x04, 'D'.code.toByte(), 'C'.code.toByte(), 'T'.code.toByte(), '1'.code.toByte()) - ) - ) - service.writeDid(0xF187, "DCT1".toByteArray(Charsets.US_ASCII)) - - // 3. 전체 DID 읽기 (0x18 -> 0x98) - val allDidPayload = byteArrayOf( - 0x01, // status success - 0xF1.toByte(), 0x87.toByte(), 0x02, 'P'.code.toByte(), 'N'.code.toByte(), // F187 - 0xF1.toByte(), 0x00.toByte(), 0x03, 'S'.code.toByte(), 'W'.code.toByte(), '1'.code.toByte() // F100 - ) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_REQ_ALL_DID_INFO.code }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_REQ_ALL_DID_INFO, allDidPayload) - ) - val allResults = service.readAllDid(0x41) - assertEquals(2, allResults.size) - assertEquals("PN", allResults[0xF187]?.toString(Charsets.US_ASCII)) - assertEquals("SW1", allResults[0xF100]?.toString(Charsets.US_ASCII)) - - // 4. Setup DID 프리셋 저장 (0x17 -> 0x97) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_SETUP_DID_INFO.code }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_SETUP_DID_INFO, byteArrayOf(0x01)) - ) - service.setupDid(0x7F) - } - - @Test - fun testSavedLogRemoveProtocolAndService() = runTest { - // [ISSUE-R3-03] 0x26 단말 로그 삭제 패킷 빌더 검증 - val packet = DctProtocolRequests.savedLogRemove(0x0102) - assertEquals(DctCommand.CMD_SAVED_LOGFILE_COPY.code, packet.command) - assertArrayEquals(byteArrayOf(0x03, 0x01, 0x02), packet.payload) - - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) - - // 성공 응답 (0x03, 0x01) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_SAVED_LOGFILE_COPY.code && it.payload[0] == 0x03.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_SAVED_LOGFILE_COPY, byteArrayOf(0x03, 0x01)) - ) - - val service = MaintenanceService(repo) - service.deleteLogFile(0x0102) - - // 장비 거절 응답 (0x03, 0x02) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_SAVED_LOGFILE_COPY.code && it.payload[0] == 0x03.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_SAVED_LOGFILE_COPY, byteArrayOf(0x03, 0x02)) - ) - assertTrue(runCatching { service.deleteLogFile(0x0102) }.isFailure) - - // 유효하지 않은 인덱스 (16비트 초과) 방어 - assertTrue(runCatching { service.deleteLogFile(0x10000) }.isFailure) - } - - @Test - fun testSavedLogDescendingDeleteOrder() { - // PC CLogfileDownloadDlg.cpp m_listDeleteItem.rbegin() 과 동등한 역순(내림차순) 정렬 검증 - val selectedIndices = setOf(2, 10, 5, 1, 8) - val sortedForDelete = selectedIndices.sortedDescending() - - assertEquals(listOf(10, 8, 5, 2, 1), sortedForDelete) - - // RemoteLogFile 필터링 및 정렬 동작 검증 - val files = listOf( - RemoteLogFile(1, "log_01.csv"), - RemoteLogFile(2, "log_02.csv"), - RemoteLogFile(5, "log_05.csv") - ) - val selectedFiles = files.filter { it.index in setOf(1, 5) }.map { it.index }.sortedDescending() - assertEquals(listOf(5, 1), selectedFiles) - } - - @Test - fun testCarTypeAndDatabaseMaintenanceParity() = runTest { - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) - - // 1. 차종 목록 조회 (0x07 / 0x08) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_CARTYPE_CONTROL.code && it.payload[0] == 0x00.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_CARTYPE_CONTROL, byteArrayOf(0x00, 0x01, 0x01, 0x02)) // 2개 차종 - ) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_CARTYPE_CONTROL.code && it.payload[0] == 0x01.toByte() && it.payload[2] == 0.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_CARTYPE_CONTROL, byteArrayOf(0x01, 0x01, 0x01, 0x00, 0x03, 'S'.code.toByte(), 'G'.code.toByte(), '2'.code.toByte())) - ) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_CARTYPE_CONTROL.code && it.payload[0] == 0x01.toByte() && it.payload[2] == 1.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_CARTYPE_CONTROL, byteArrayOf(0x01, 0x01, 0x01, 0x01, 0x03, 'N'.code.toByte(), 'Q'.code.toByte(), '5'.code.toByte())) - ) - - val service = MaintenanceService(repo) - val carList = service.listCarTypes(1) - assertEquals(listOf("SG2", "NQ5"), carList) - - // 2. 차종 삭제 - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_CARTYPE_CONTROL.code && it.payload[0] == 0x02.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_CARTYPE_CONTROL, byteArrayOf(0x02, 0x01, 0x01)) - ) - service.deleteCarType(1, 0) - - // 3. DB 목록 및 선택/삭제 - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_DB.code && it.payload[0] == 0x01.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_DB, byteArrayOf(0x01, 0x01, 0x01)) // 1개 DB - ) - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_DB.code && it.payload[0] == 0x02.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_DB, byteArrayOf(0x02, 0x01, 0x00, 0x04, 'T'.code.toByte(), 'E'.code.toByte(), 'S'.code.toByte(), 'T'.code.toByte())) - ) - val dbs = service.listDatabases() - assertEquals(listOf("TEST"), dbs) - - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_DB.code && it.payload[0] == 0x03.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_DB, byteArrayOf(0x03, 0x01)) - ) - service.deleteDatabase(0) - - coEvery { - repo.sendCommand(match { it.command == DctCommand.CMD_DB.code && it.payload[0] == 0x00.toByte() }) - } returns DctResult.Success( - DctPacket.create(DctCommand.RES_DB, byteArrayOf(0x00, 0x01)) - ) - service.selectDatabase("TEST") - } - - @Test - fun testMaintenanceTabStringFormattingParity() { - // 1. UdsDidTab 문자열 템플릿 보간자 검증 - val f187 = STANDARD_DIDS.first { it.did == 0xF187 } - assertEquals("0xF187", "0x${f187.didHex}") - assertEquals("최대 10자 입력", "최대 ${f187.maxChars}자 입력") - val sampleBytes = byteArrayOf(1, 2, 3, 4) - assertEquals("DID 0xF187 읽기 성공 (4 bytes)", "DID 0x${f187.didHex} 읽기 성공 (${sampleBytes.size} bytes)") - val readResults = mapOf(0xF187 to sampleBytes, 0xF100 to byteArrayOf(0x01)) - assertEquals("DID 일괄 읽기 완료 (2개 응답 수신)", "DID 일괄 읽기 완료 (${readResults.size}개 응답 수신)") - - // 2. VehicleDbTab 문자열 템플릿 보간자 검증 - val selectedMaker = 1 - assertEquals("단말 차종 목록 조회 중 (Maker=1)...", "단말 차종 목록 조회 중 (Maker=$selectedMaker)...") - val carList = listOf("SG2", "NQ5") - assertEquals("(2개)", "(${carList.size}개)") - assertEquals("[#0] SG2", "[#0] ${carList[0]}") - assertEquals("[#1] NQ5", "[#1] ${carList[1]}") - val selectedCount = 2 - assertEquals("삭제 (2)", "삭제 ($selectedCount)") - val dbList = listOf("TEST_DB") - assertEquals("(1개)", "(${dbList.size}개)") - assertEquals("[#0] TEST_DB", "[#0] ${dbList[0]}") - val targetIndices = listOf(0, 1) - assertEquals("선택한 2개 차종을 단말에서 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", "선택한 ${targetIndices.size}개 차종을 단말에서 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") - assertEquals("2개 차종 삭제 완료", "${targetIndices.size}개 차종 삭제 완료") - - // 3. LogTab 문자열 템플릿 보간자 검증 - val logFiles = listOf(RemoteLogFile(1, "log01.csv"), RemoteLogFile(2, "log02.csv")) - assertEquals("(2개 파일)", "(${logFiles.size}개 파일)") - assertEquals("선택 로그 삭제 (1)", "선택 로그 삭제 (1)") - assertEquals("#1", "#${logFiles[0].index}") - assertEquals("#2", "#${logFiles[1].index}") - assertEquals("선택한 2개의 로그 파일을 단말 플래시 메모리에서 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.", "선택한 ${logFiles.size}개의 로그 파일을 단말 플래시 메모리에서 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") - assertEquals("2개 로그 파일 삭제 완료", "${logFiles.size}개 로그 파일 삭제 완료") - } -} diff --git a/Android/app/src/test/java/com/dct/app/presentation/monitor/components/RibbonSubmenuPositionTest.kt b/Android/app/src/test/java/com/dct/app/presentation/monitor/components/RibbonSubmenuPositionTest.kt deleted file mode 100644 index 4845208..0000000 --- a/Android/app/src/test/java/com/dct/app/presentation/monitor/components/RibbonSubmenuPositionTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.dct.app.presentation.monitor.components - -import androidx.compose.ui.unit.* -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class RibbonSubmenuPositionTest { - private val provider = RibbonSubmenuPositionProvider() - - @Test fun opensToRightAlignedWithSelectedCategory() { - assertEquals(IntOffset(280, 180), provider.calculatePosition( - IntRect(20, 180, 280, 228), IntSize(1200, 800), LayoutDirection.Ltr, IntSize(240, 160) - )) - } - - @Test fun narrowScreenFallsBackToLeftAndAvoidsBottomClipping() { - assertEquals(IntOffset(360, 340), provider.calculatePosition( - IntRect(600, 450, 860, 498), IntSize(900, 500), LayoutDirection.Ltr, IntSize(240, 160) - )) - } - - @Test fun oversizedPopupNeverProducesNegativeCoordinates() { - assertEquals(IntOffset.Zero, provider.calculatePosition( - IntRect(10, 20, 90, 68), IntSize(100, 80), LayoutDirection.Ltr, IntSize(240, 160) - )) - } - - @Test fun nestedMenuAddsParentWindowOriginToBothCoordinates() { - val nested = RibbonSubmenuPositionProvider { IntOffset(55, 190) } - assertEquals(IntOffset(335, 370), nested.calculatePosition( - IntRect(20, 180, 280, 228), IntSize(1200, 800), LayoutDirection.Ltr, IntSize(240, 160) - )) - } - - @Test fun fallbackUsesScreenCoordinatesRatherThanLocalMenuCoordinates() { - val nested = RibbonSubmenuPositionProvider { IntOffset(600, 190) } - assertEquals(IntOffset(380, 340), nested.calculatePosition( - IntRect(20, 180, 280, 228), IntSize(900, 500), LayoutDirection.Ltr, IntSize(240, 160) - )) - } - - @Test fun parentWindowOriginIsReadAgainAfterRelocation() { - var offset = IntOffset(55, 190) - val nested = RibbonSubmenuPositionProvider { offset } - val anchor = IntRect(20, 180, 280, 228) - assertEquals(IntOffset(335, 370), nested.calculatePosition( - anchor, IntSize(1200, 800), LayoutDirection.Ltr, IntSize(240, 160) - )) - offset = IntOffset(85, 220) - assertEquals(IntOffset(365, 400), nested.calculatePosition( - anchor, IntSize(1200, 800), LayoutDirection.Ltr, IntSize(240, 160) - )) - } -} diff --git a/Android/app/src/test/java/com/dct/app/presentation/signaltree/SignalTableTextStylesTest.kt b/Android/app/src/test/java/com/dct/app/presentation/signaltree/SignalTableTextStylesTest.kt deleted file mode 100644 index bc0df83..0000000 --- a/Android/app/src/test/java/com/dct/app/presentation/signaltree/SignalTableTextStylesTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.dct.app.presentation.signaltree - -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class SignalTableTextStylesTest { - @Test - fun `normal and tablet font scales retain compact rows`() { - for (scale in listOf(1f, 1.3f)) { - for (density in listOf(1f, 1.75f, 3f)) { - assertEquals(24.dp, signalTableRowHeight(Density(density, scale))) - } - } - } - - @Test - fun `large fonts reserve room for every cell style and vertical padding`() { - for (scale in listOf(1.3f, 1.5f, 2f, 3f)) { - with(Density(1.75f, scale)) { - val row = signalTableRowHeight(this) - for (style in listOf(SignalTableTextStyle, SignalTableDetailTextStyle, SignalTableColorTextStyle)) { - assertTrue(row >= style.lineHeight.toDp() + 4.dp) - assertTrue(style.lineHeight > style.fontSize) - assertEquals(false, style.platformStyle?.paragraphStyle?.includeFontPadding) - } - } - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/presentation/signaltree/TableAutoFitTest.kt b/Android/app/src/test/java/com/dct/app/presentation/signaltree/TableAutoFitTest.kt deleted file mode 100644 index 04acfd8..0000000 --- a/Android/app/src/test/java/com/dct/app/presentation/signaltree/TableAutoFitTest.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.dct.app.presentation.signaltree - -import androidx.compose.ui.unit.dp -import com.dct.app.data.repository.DpcWorkspaceJson -import com.dct.app.domain.model.project.DpcPageState -import com.dct.app.domain.model.project.DpcWindowState -import com.dct.app.domain.model.project.DpcWorkspaceState -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class TableAutoFitTest { - @Test fun `opening respects default header controls and fractional clipping`() { - assertEquals(110.dp, calculateAutoFitWidth(110.dp, 30.dp, 20.dp)) - assertEquals(126.25.dp, calculateAutoFitWidth(110.dp, 90.25.dp, 50.dp)) - assertEquals(110.25.dp, calculateAutoFitWidth(110.dp, 30.dp, 101.25.dp)) - } - - @Test fun `checkbox and nested description padding fit and maximum matches manual resize`() { - assertEquals(161.dp, calculateAutoFitWidth(130.dp, 30.dp, 130.dp, 30.dp)) - assertEquals(209.dp, calculateAutoFitWidth(140.dp, 30.dp, 190.dp, 18.dp)) - assertEquals(1200.dp, calculateAutoFitWidth(110.dp, 100.dp, 2000.dp)) - } - - @Test fun `old saved narrow width opens instead of closing and close restores default`() { - val store = WindowUiStore(mapOf("width:name" to "70.0", "search" to "RPM")) {} - val widths = WindowColumnWidths(store) - assertFalse(widths.isAutoFit("name", 151.dp)) - widths.toggleAutoFit("name", 110.dp, 151.dp) - assertEquals(151.dp, widths["name"]) - assertTrue(widths.isAutoFit("name", 151.dp)) - widths.toggleAutoFit("name", 110.dp, 151.dp) - assertNull(widths["name"]) - assertFalse(store.values.containsKey("autoFit:name")) - assertEquals("RPM", store.values["search"]) - } - - @Test fun `manual drag clears fit mode even when width is larger than default`() { - val widths = WindowColumnWidths(WindowUiStore(emptyMap()) {}) - widths.toggleAutoFit("name", 110.dp, 200.dp) - widths["name"] = 150.dp - assertFalse(widths.isAutoFit("name", 200.dp)) - widths.toggleAutoFit("name", 110.dp, 200.dp) - assertEquals(200.dp, widths["name"]) - widths["name"] = 300.dp - assertFalse(widths.isAutoFit("name", 200.dp)) - widths.toggleAutoFit("name", 110.dp, 200.dp) - assertEquals(300.dp, widths["name"]) - assertTrue(widths.isAutoFit("name", 200.dp)) - } - - @Test fun `fractional stale fit is reopened not collapsed after metadata or scale change`() { - val widths = WindowColumnWidths(WindowUiStore(emptyMap()) {}) - widths.toggleAutoFit("name", 110.dp, 180.dp) - assertFalse(widths.isAutoFit("name", 180.25.dp)) - widths.toggleAutoFit("name", 110.dp, 180.25.dp) - assertEquals(180.25.dp, widths["name"]) - assertTrue(widths.isAutoFit("name", 180.25.dp)) - } - - @Test fun `width and fit mode are saved atomically and round trip with window preferences`() { - var writes = 0 - val store = WindowUiStore(mapOf("width:value" to "65.0")) { writes++ } - val widths = WindowColumnWidths(store) - widths.toggleAutoFit("name", 110.dp, 200.dp) - assertEquals(1, writes) - val window = DpcWindowState("one", "MDB_SIGNAL_LIST", "MDB", contentState = store.values) - val workspace = DpcWorkspaceState("p", listOf(DpcPageState("p", "Page", listOf(window)))) - val restored = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!! - val preferences = restored.pages.single().windows.single().contentState - val reopened = WindowColumnWidths(WindowUiStore(preferences) {}) - assertEquals(200.dp, reopened["name"]) - assertTrue(reopened.isAutoFit("name", 200.dp)) - assertEquals("65.0", preferences["width:value"]) - widths["name"] = Float.NaN.dp - assertEquals(1, writes) - assertTrue(widths.isAutoFit("name", 200.dp)) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/A2lCharacteristicLayoutTest.kt b/Android/app/src/test/java/com/dct/app/tests/A2lCharacteristicLayoutTest.kt deleted file mode 100644 index 4097266..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/A2lCharacteristicLayoutTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.parser.A2lParser -import com.dct.app.domain.model.a2l.A2lDataType -import com.dct.app.data.protocol.DctProtocolRequests -import org.junit.Assert.* -import org.junit.Test - -class A2lCharacteristicLayoutTest { - private fun parse(dimensions: String = "20 2", fnc: String = "1 SWORD COLUMN_DIR DIRECT") = - A2lParser.parseA2lFull(""" - /begin CHARACTERISTIC Table "table" - VAL_BLK 0x011E0020 AUTO_EXTRACTION_VALUE_S16 0 NO_COMPU_METHOD -32768 32767 - MATRIX_DIM $dimensions - /end CHARACTERISTIC - /begin CHARACTERISTIC Scalar "scalar" - VALUE 0x011E0000 AUTO_EXTRACTION_VALUE_S16 0 NO_COMPU_METHOD -32768 32767 - /end CHARACTERISTIC - /begin RECORD_LAYOUT AUTO_EXTRACTION_VALUE_S16 - FNC_VALUES $fnc - /end RECORD_LAYOUT - """.trimIndent().byteInputStream()) - - @Test fun recordLayoutAfterCharacteristicsResolvesActualTypeInsteadOfName() { - val result = parse() - assertEquals(A2lDataType.SWORD, result.characteristics.single().a2lDataType) - val array = result.characteristicsArray.single() - assertEquals("SWORD", array.dataType) - assertEquals("COLUMN_DIR", array.indexMode) - assertEquals(40, array.values.size) - assertNull(array.calibrationLayoutError) - } - - @Test fun allFortyCellsHaveUniquePcCompatibleAddressesFor20By2() { - val array = parse().characteristicsArray.single() - // PC GetArrayAddressIDby2D: base + (2*x+y)*2, not row-major y*20+x. - for (y in 0..1) for (x in 0..19) { - assertEquals(0x011E0020L + (2 * x + y) * 2, array.cellAddress(y * 20 + x)) - } - assertEquals(40, array.values.indices.map(array::cellAddress).distinct().size) - assertEquals(0x011E0024L, array.cellAddress(1)) - assertEquals(0x011E0022L, array.cellAddress(20)) - } - - @Test fun columnOrderUsesActualRowCountInsteadOfHardcodedTwo() { - val array = parse("4 3").characteristicsArray.single() - assertEquals(0x011E0026L, array.cellAddress(1)) - assertEquals(0x011E0022L, array.cellAddress(4)) - assertEquals(0x011E0036L, array.cellAddress(11)) - } - - @Test fun rowOrderAndOneDimensionalArraysRemainContiguous() { - val row = parse("4 3", "1 UWORD ROW_DIR DIRECT").characteristicsArray.single() - for (i in row.values.indices) assertEquals(0x011E0020L + i * 2, row.cellAddress(i)) - val one = parse("20").characteristicsArray.single() - for (i in one.values.indices) assertEquals(0x011E0020L + i * 2, one.cellAddress(i)) - } - - @Test fun declaredTypeOverridesMisleadingDepositNameIncluding64BitTypes() { - for ((declared, expected) in listOf("UBYTE" to A2lDataType.UBYTE, "UWORD" to A2lDataType.UWORD, - "A_UINT64" to A2lDataType.UINT64, "A_INT64" to A2lDataType.INT64, - "FLOAT64_IEEE" to A2lDataType.FLOAT64_IEEE)) { - assertEquals(expected, parse(fnc = "1 $declared ROW_DIR DIRECT").characteristicsArray.single().a2lDataType) - } - } - - @Test fun unsupportedLayoutsRejectAddressCalculationInsteadOfWritingGuessedBytes() { - for (fnc in listOf("2 SWORD COLUMN_DIR DIRECT", "1 SWORD COLUMN_DIR PWORD", - "1 SWORD ALTERNATE_CURVES DIRECT", "1 UNKNOWN COLUMN_DIR DIRECT")) { - val result = parse(fnc = fnc) - assertNotNull(result.characteristics.single().calibrationLayoutError) - val array = result.characteristicsArray.single() - assertNotNull(array.calibrationLayoutError) - assertThrows(IllegalArgumentException::class.java) { array.cellAddress(0) } - } - } - - @Test fun missingLayoutDoesNotSilentlyEnableUnknownTypeOrMultidimensionalWrites() { - val unknown = A2lParser.parseA2lFull(""" - /begin CHARACTERISTIC Unknown "unknown" - VAL_BLK 0x1000 MissingLayout 0 NO_COMPU_METHOD 0 100 MATRIX_DIM 2 2 - /end CHARACTERISTIC - """.trimIndent().byteInputStream()).characteristicsArray.single() - assertNotNull(unknown.calibrationLayoutError) - assertThrows(IllegalArgumentException::class.java) { unknown.cellAddress(0) } - // 이름에 자료형이 들어 있어도 실제 선언이 없으면 장비 쓰기를 허용하지 않는다. - val named = A2lParser.parseA2lFull(""" - /begin CHARACTERISTIC Named "named" - VALUE 0x1000 Deposit_UWORD 0 NO_COMPU_METHOD 0 100 - /end CHARACTERISTIC - """.trimIndent().byteInputStream()).characteristics.single() - assertEquals(A2lDataType.UWORD, named.a2lDataType) - assertNotNull(named.calibrationLayoutError) - } - - @Test fun negativeOutOfBoundsHigherDimensionalAndOverflowAddressesAreRejected() { - val array = parse().characteristicsArray.single() - for (index in listOf(-1, 40)) assertThrows(IllegalArgumentException::class.java) { array.cellAddress(index) } - assertThrows(IllegalArgumentException::class.java) { parse("2 2 2").characteristicsArray.single().cellAddress(0) } - assertThrows(IllegalArgumentException::class.java) { array.copy(ecuAddress = 0xFFFF_FFFFL).cellAddress(0) } - assertThrows(IllegalArgumentException::class.java) { array.copy(ecuAddress = Long.MAX_VALUE).cellAddress(0) } - } - - @Test fun correctedCellAddressAndSignedValueProducePcCompatibleWriteAndReadPayloads() { - val array = parse().characteristicsArray.single() - val address = array.cellAddress(1) - val write = DctProtocolRequests.xcpCharacteristicWrite(address, array.a2lDataType, -123.0, false) - assertArrayEquals(byteArrayOf(3, 0x85.toByte(), 0xFF.toByte(), 0x24, 0, 0x1E, 1), write.payload) - val read = DctProtocolRequests.xcpCharacteristicRead(address, array.a2lDataType, false) - assertArrayEquals(byteArrayOf(3, 0x24, 0, 0x1E, 1), read.payload) - assertEquals(-123.0, array.a2lDataType.unpackRawValue(write.payload, 1, false), 0.0) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt deleted file mode 100644 index a51d161..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.parser.A2lParser -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.ByteArrayInputStream - -class A2lParserTest { - - @Test - fun `XCP ON CAN 설정과 COMPU 포맷 단위를 PC처럼 상속한다`() { - val sample = """ - /begin IF_DATA XCP - BYTE_ORDER_MSB_FIRST - /end IF_DATA - /begin IF_DATA XCP - /begin XCP_ON_CAN - CAN_ID_MASTER 0x690 - CAN_ID_SLAVE 0x693 - BYTE_ORDER_MSB_LAST - /end XCP_ON_CAN - /end IF_DATA - /begin IF_DATA XCP - BYTE_ORDER_MSB_FIRST - /end IF_DATA - /begin COMPU_METHOD Conv "conversion" - LINEAR "%8.4" "km/h" - COEFFS_LINEAR 0.5 1.0 - /end COMPU_METHOD - /begin MEASUREMENT VehicleSpeed "speed" - UWORD Conv 1 0 0 300 - ECU_ADDRESS 0x1000 - SYMBOL_LINK "ECU.VehicleSpeed" 0 - /end MEASUREMENT - /begin MEASUREMENT Temperature "temp" - SWORD Conv 1 0 -40 200 - ECU_ADDRESS 0x1002 - FORMAT "%5.1f" - PHYS_UNIT "degC" - /end MEASUREMENT - """.trimIndent() - - val result = A2lParser.parseA2lFull(ByteArrayInputStream(sample.toByteArray())) - assertEquals(0x690L, result.ifData!!.masterCanId) - assertEquals(false, result.ifData!!.isMsbFirst) - val speed = result.measurements.first { it.name == "VehicleSpeed" } - assertEquals("ECU.VehicleSpeed", result.rawMeasurements.first { it.name == "VehicleSpeed" }.symbolLink) - assertEquals("%8.4", speed.measurementFormat) - assertEquals("km/h", speed.unit) - assertEquals("2.2350", speed.formatMeasurementValue(2.2346)) - val temperature = result.measurements.first { it.name == "Temperature" } - assertEquals("%5.1f", temperature.measurementFormat) - assertEquals("℃", temperature.unit) - } - - @Test - fun testA2lParsing() { - val sampleA2l = """ - ASAP2_VERSION 1 60 - /begin PROJECT TestProject "Project Description" - /begin MODULE TestModule "Module Description" - - /begin IF_DATA XCP - /begin XCP_ON_CAN - CAN_ID_MASTER 0x7E0 - CAN_ID_SLAVE 0x7E8 - BAUDRATE 500000 - BYTE_ORDER MSB_LAST - /end XCP_ON_CAN - /end IF_DATA - - /begin COMPU_VTAB StateVTab "State verbal mapping" - TAB_VERB - 0 "INIT" - 1 "RUNNING" - 2 "ERROR" - /end COMPU_VTAB - - /begin COMPU_TAB IntpTab "Piecewise linear mapping" - TAB_INTP - 0.0 0.0 - 50.0 100.0 - 100.0 300.0 - /end COMPU_TAB - - /begin COMPU_METHOD EngineSpeedConv "Engine Speed conversion" - LINEAR "%6.2f" "rpm" - COEFFS_LINEAR 0.25 0.0 - /end COMPU_METHOD - - /begin COMPU_METHOD ThrottlePosConv "Throttle Pos conversion" - LINEAR "%5.1f" "%" - COEFFS_LINEAR 0.1 0.0 - /end COMPU_METHOD - - /begin COMPU_METHOD StateConv "State conversion" - TAB_VERB "%s" "" StateVTab - /end COMPU_METHOD - - /begin COMPU_METHOD CustomCurveConv "Custom curve conversion" - TAB_INTP "%6.1f" "bar" IntpTab - /end COMPU_METHOD - - /begin MEASUREMENT EngineSpeed "Current engine speed in RPM" - UWORD EngineSpeedConv 1 0.0 0.0 8000.0 - ECU_ADDRESS 0x70001000 - FORMAT "%6.2f" - /end MEASUREMENT - - /begin MEASUREMENT ThrottlePos "Throttle valve position" - UBYTE ThrottlePosConv 1 0.0 0.0 100.0 - ECU_ADDRESS 0x70001004 - FORMAT "%5.1f" - /end MEASUREMENT - - /begin MEASUREMENT TempArray "Temperature Array" - SWORD NO_COMPU_METHOD 1 0.0 -40.0 150.0 - ECU_ADDRESS 0x70001008 - MATRIX_DIM 2 - /end MEASUREMENT - - /begin CHARACTERISTIC K_IDLE_SPEED "Idle Target Speed" - VALUE - 0x70002000 - Deposit_UWORD - 0.0 - EngineSpeedConv - 600.0 - 1200.0 - FORMAT "%6.2f" - UNIT "rpm" - /end CHARACTERISTIC - - /begin CHARACTERISTIC MAP_THROTTLE_CURVE "Throttle Map Array" - VAL_BLK - 0x70002020 - Deposit_UWORD - 0.0 - ThrottlePosConv - 0.0 - 100.0 - MATRIX_DIM 4 - FORMAT "%5.1f" - UNIT "%" - /end CHARACTERISTIC - - /end MODULE - /end PROJECT - """.trimIndent() - - val inputStream = ByteArrayInputStream(sampleA2l.toByteArray(Charsets.UTF_8)) - val result = A2lParser.parseA2lFull(inputStream) - - // 1. IF_DATA 검증 - assertNotNull(result.ifData) - assertEquals(0x7E0L, result.ifData!!.masterCanId) - assertEquals(0x7E8L, result.ifData!!.slaveCanId) - assertEquals(500000, result.ifData!!.baudrate) - assertEquals(false, result.ifData!!.isMsbFirst) - - // 2. COMPU_VTAB 및 COMPU_TAB 검증 - val stateMethod = result.compuMethods["stateconv"] - assertNotNull(stateMethod) - assertEquals("INIT", stateMethod!!.getVerbalString(0.0)) - assertEquals("RUNNING", stateMethod.getVerbalString(1.0)) - assertEquals("ERROR", stateMethod.getVerbalString(2.0)) - - val curveMethod = result.compuMethods["customcurveconv"] - assertNotNull(curveMethod) - assertEquals(0.0, curveMethod!!.calculatePhysicalValue(0.0), 0.001) - assertEquals(50.0, curveMethod.calculatePhysicalValue(25.0), 0.001) // 0~50 interpolate - assertEquals(100.0, curveMethod.calculatePhysicalValue(50.0), 0.001) - assertEquals(200.0, curveMethod.calculatePhysicalValue(75.0), 0.001) // 50~100 interpolate - assertEquals(300.0, curveMethod.calculatePhysicalValue(100.0), 0.001) - - // 3. Measurements 검증 - val signals = result.measurements - assertEquals(4, signals.size) // EngineSpeed, ThrottlePos, TempArray[0], TempArray[1] - - val speed = signals.find { it.name == "EngineSpeed" } - assertNotNull(speed) - assertEquals(0x70001000.toInt(), speed!!.adl) - assertEquals("rpm", speed.unit) - assertEquals(0.25, speed.cvsA, 0.0001) - assertEquals(8000.0, speed.max, 0.0001) - - val throttle = signals.find { it.name == "ThrottlePos" } - assertNotNull(throttle) - assertEquals(0x70001004.toInt(), throttle!!.adl) - assertEquals("%", throttle.unit) - assertEquals(0.1, throttle.cvsA, 0.0001) - - val temp0 = signals.find { it.name == "TempArray[0]" } - val temp1 = signals.find { it.name == "TempArray[1]" } - assertNotNull(temp0) - assertNotNull(temp1) - assertEquals(0x70001008.toInt(), temp0!!.adl) - assertEquals(0x7000100A.toInt(), temp1!!.adl) // SWORD = 2 bytes offset - - // 4. Characteristics 검증 - val characteristics = result.characteristics - assertEquals(1, characteristics.size) - val idleSpeed = characteristics[0] - assertEquals("K_IDLE_SPEED", idleSpeed.name) - assertEquals(0x70002000L, idleSpeed.ecuAddress) - assertEquals("rpm", idleSpeed.unit) - assertEquals("VALUE", idleSpeed.type) - assertEquals(600.0, idleSpeed.lowerLimit, 0.0001) - assertEquals(1200.0, idleSpeed.upperLimit, 0.0001) - - // 5. CharacteristicsArray 검증 - val charArrays = result.characteristicsArray - assertEquals(1, charArrays.size) - val throttleCurve = charArrays[0] - assertEquals("MAP_THROTTLE_CURVE", throttleCurve.name) - assertEquals(0x70002020L, throttleCurve.ecuAddress) - assertEquals("VAL_BLK", throttleCurve.type) - assertEquals(listOf(4), throttleCurve.matrixDim) - assertEquals(4, throttleCurve.values.size) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/AlphanumericalUpdatesTest.kt b/Android/app/src/test/java/com/dct/app/tests/AlphanumericalUpdatesTest.kt deleted file mode 100644 index 78f80b8..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/AlphanumericalUpdatesTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.SignalDefinition -import com.dct.app.presentation.monitor.components.AlphaSignal -import com.dct.app.presentation.monitor.components.alphaDisplayValues -import com.dct.app.presentation.monitor.components.resolveAlphaSignalSelection -import com.dct.app.presentation.monitor.components.selectAlphaValues -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class AlphanumericalUpdatesTest { - private fun signal(name: String = "RPM", idx: Int = 1) = SignalDefinition( - 1, idx, name, MdbAttrType.ATTR_2_UINT16, 0.1, 0.0, "", 0x123456, 0.0, 100.0 - ) - - @Test - fun restoredAliasesKeepOrderDeduplicateAndReportOnlyMissingReferences() { - val mdb = signal() - val can = signal("Speed", 7).copy(messageId = 291) - val result = resolveAlphaSignalSelection( - listOf("can_7", "sig_1_1", "can_291_Speed", "missing", "mdb_sig_1_1"), - listOf(can), listOf(mdb), emptyList(), emptyList(), emptyList() - ) - assertEquals(listOf("can_291_Speed", "sig_1_1"), result.signals.map { it.id }) - assertEquals(listOf("missing"), result.unresolvedIds) - assertSame(mdb, result.signals.last().definition) - } - - @Test - fun sourceReplacementRefreshesDefinitionAndResolvesPreviouslyMissingSignal() { - val ids = listOf("sig_1_1", "a2l_4660") - val old = resolveAlphaSignalSelection(ids, emptyList(), listOf(signal()), emptyList(), emptyList(), emptyList()) - val replacement = signal("New RPM").copy(unit = "rpm", color = 0x0000FF) - val a2l = signal("Pressure").copy(adl = 4660, measurementFormat = "%6.2f") - val updated = resolveAlphaSignalSelection(ids, emptyList(), listOf(replacement), listOf(a2l), emptyList(), emptyList()) - - assertEquals(listOf("a2l_4660"), old.unresolvedIds) - assertTrue(updated.unresolvedIds.isEmpty()) - assertSame(replacement, updated.signals.first().definition) - assertEquals("12.35", updated.signals.last().formatValue(12.345)) - } - - @Test - fun valueProjectionPreservesCanonicalPriorityNameFallbackAndMissingValues() { - val entries = listOf(AlphaSignal("sig_1_1", signal()), AlphaSignal("sig_1_2", signal("Temp", 2))) - assertEquals( - mapOf("sig_1_1" to 10.0, "sig_1_2" to 20.0), - selectAlphaValues(entries, mapOf("sig_1_1" to 10.0, "RPM" to 99.0, "Temp" to 20.0, "other" to 50.0)) - ) - val cleared = selectAlphaValues(entries, emptyMap()) - assertEquals(setOf("sig_1_1", "sig_1_2"), cleared.keys) - assertNull(cleared["sig_1_1"]) - assertEquals("N/A", entries.first().formatValue(cleared["sig_1_1"])) - } - - @Test - fun unrelatedPacketsDoNotPublishNewAlphaValues() = runTest { - val entry = AlphaSignal("sig_1_1", signal()) - val telemetry = MutableStateFlow(mapOf("sig_1_1" to 1.0)) - val received = mutableListOf>() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - telemetry.alphaDisplayValues(listOf(entry)).collect { received.add(it) } - } - advanceTimeBy(34) - runCurrent() - repeat(5) { i -> - telemetry.value = mapOf(entry.id to 1.0, "other" to i.toDouble()) - advanceTimeBy(34) - runCurrent() - } - assertEquals(listOf(mapOf(entry.id to 1.0)), received) - } - - @Test - fun burstUsesLatestValueAndPublishesFinalValueWithoutAnotherPacket() = runTest { - val entry = AlphaSignal("sig_1_1", signal()) - val telemetry = MutableStateFlow>(emptyMap()) - val received = mutableListOf>() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - telemetry.alphaDisplayValues(listOf(entry)).collect { received.add(it) } - } - repeat(20) { i -> - telemetry.value = mapOf(entry.id to i.toDouble()) - advanceTimeBy(1) - } - assertTrue(received.isEmpty()) - advanceTimeBy(14) - runCurrent() - assertEquals(listOf(mapOf(entry.id to 19.0)), received) - telemetry.value = emptyMap() - advanceTimeBy(34) - runCurrent() - assertEquals(mapOf(entry.id to null), received.last()) - assertEquals(2, received.size) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/AttrConverterTest.kt b/Android/app/src/test/java/com/dct/app/tests/AttrConverterTest.kt deleted file mode 100644 index 32bf921..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/AttrConverterTest.kt +++ /dev/null @@ -1,213 +0,0 @@ -package com.dct.app.tests - -import com.google.common.truth.Truth.assertThat -import com.dct.app.data.mdb.AttrConverter -import com.dct.app.domain.model.mdb.MdbAttrType -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows - -@DisplayName("M3 AttrConverter: ATTR 0~5 Little-Endian 언팩/패킹 및 물리값 선형 변환 검증") -class AttrConverterTest { - - // ========================================================================= - // 1. ATTR 0: 1-Byte Unsigned Integer (0 ~ 255) - // ========================================================================= - - @Test - @DisplayName("ATTR 0: 0, 127, 255 경계값 언팩 및 패킹 검증") - fun testAttr0UnpackAndPack() { - val testValues = listOf(0L, 1L, 127L, 128L, 254L, 255L) - for (v in testValues) { - val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_0_UINT8) - assertThat(packed.size).isEqualTo(1) - val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_0_UINT8) - assertThat(unpacked).isEqualTo(v) - } - } - - // ========================================================================= - // 2. ATTR 1: 1-Byte Signed Integer (-128 ~ 127) - // ========================================================================= - - @Test - @DisplayName("ATTR 1: -128, -1, 0, 1, 127 경계값 언팩 및 패킹 검증") - fun testAttr1UnpackAndPack() { - val testValues = listOf(-128L, -100L, -1L, 0L, 1L, 100L, 127L) - for (v in testValues) { - val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_1_INT8) - assertThat(packed.size).isEqualTo(1) - val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_1_INT8) - assertThat(unpacked).isEqualTo(v) - } - } - - // ========================================================================= - // 3. ATTR 2: 2-Byte Unsigned Integer (0 ~ 65535, Little-Endian) - // ========================================================================= - - @Test - @DisplayName("ATTR 2: 0, 256, 32767, 65535 경계값 및 Little-Endian 바이트 오더 검증") - fun testAttr2UnpackAndPack() { - val testValues = listOf(0L, 1L, 255L, 256L, 1000L, 32767L, 32768L, 65534L, 65535L) - for (v in testValues) { - val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_2_UINT16) - assertThat(packed.size).isEqualTo(2) - - // Little-Endian 검증: 하위 바이트 우선 - val expectedB0 = (v and 0xFFL).toByte() - val expectedB1 = ((v shr 8) and 0xFFL).toByte() - assertThat(packed[0]).isEqualTo(expectedB0) - assertThat(packed[1]).isEqualTo(expectedB1) - - val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_2_UINT16) - assertThat(unpacked).isEqualTo(v) - } - } - - // ========================================================================= - // 4. ATTR 3: 2-Byte Signed Integer (-32768 ~ 32767, Little-Endian) - // ========================================================================= - - @Test - @DisplayName("ATTR 3: -32768, -1, 0, 1, 32767 음수/양수 경계값 언팩 및 패킹 검증") - fun testAttr3UnpackAndPack() { - val testValues = listOf(-32768L, -10000L, -256L, -1L, 0L, 1L, 256L, 10000L, 32767L) - for (v in testValues) { - val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_3_INT16) - assertThat(packed.size).isEqualTo(2) - - val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_3_INT16) - assertThat(unpacked).isEqualTo(v) - } - } - - // ========================================================================= - // 5. ATTR 4: 4-Byte Unsigned Integer (0 ~ 4294967295, Little-Endian) - // ========================================================================= - - @Test - @DisplayName("ATTR 4: 0, 65536, 2147483647, 4294967295 경계값 언팩 및 패킹 검증") - fun testAttr4UnpackAndPack() { - val testValues = listOf(0L, 1L, 65535L, 65536L, 2147483647L, 2147483648L, 4294967295L) - for (v in testValues) { - val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_4_UINT32) - assertThat(packed.size).isEqualTo(4) - - // Little-Endian 4바이트 검증 - val b0 = (v and 0xFFL).toByte() - val b1 = ((v shr 8) and 0xFFL).toByte() - val b2 = ((v shr 16) and 0xFFL).toByte() - val b3 = ((v shr 24) and 0xFFL).toByte() - assertThat(packed).isEqualTo(byteArrayOf(b0, b1, b2, b3)) - - val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_4_UINT32) - assertThat(unpacked).isEqualTo(v) - } - } - - // ========================================================================= - // 6. ATTR 5: 4-Byte Signed Integer (-2147483648 ~ 2147483647, Little-Endian) - // ========================================================================= - - @Test - @DisplayName("ATTR 5: -2147483648, -1, 0, 1, 2147483647 32비트 부호 정수 경계값 검증") - fun testAttr5UnpackAndPack() { - val testValues = listOf(-2147483648L, -1000000L, -1L, 0L, 1L, 1000000L, 2147483647L) - for (v in testValues) { - val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_5_INT32) - assertThat(packed.size).isEqualTo(4) - - val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_5_INT32) - assertThat(unpacked).isEqualTo(v) - } - } - - // ========================================================================= - // 7. 물리값 선형 변환 수식: Y = A * X + B 및 X = (Y - B) / A - // ========================================================================= - - @Test - @DisplayName("물리값 선형 변환 수식(Y = A * X + B) 및 역변환(X = (Y - B) / A) 정밀도 검증") - fun testPhysicalFormulaScalingAndInverse() { - // 케이스 1: 엔진 RPM (CVS_A = 1.0, CVS_B = 0.0) -> 850 raw -> 850.0 rpm - val rpmPhysical = AttrConverter.toPhysicalValue(850, 1.0, 0.0) - assertThat(rpmPhysical).isEqualTo(850.0) - val rpmRaw = AttrConverter.toRawValue(850.0, 1.0, 0.0) - assertThat(rpmRaw).isEqualTo(850.0) - - // 케이스 2: 냉각수 온도 (CVS_A = 0.1, CVS_B = -40.0) -> 1250 raw -> 85.0 °C - val tempPhysical = AttrConverter.toPhysicalValue(1250, 0.1, -40.0) - assertThat(tempPhysical).isEqualTo(85.0) - val tempRaw = AttrConverter.toRawValue(85.0, 0.1, -40.0) - assertThat(tempRaw).isEqualTo(1250.0) - - // 케이스 3: 스로틀 개도율 (CVS_A = 0.5, CVS_B = 0.0) -> 150 raw -> 75.0 % - val throttlePhysical = AttrConverter.toPhysicalValue(150, 0.5, 0.0) - assertThat(throttlePhysical).isEqualTo(75.0) - val throttleRaw = AttrConverter.toRawValue(75.0, 0.5, 0.0) - assertThat(throttleRaw).isEqualTo(150.0) - - // 케이스 4: 클러치 유압 (CVS_A = 0.05, CVS_B = 0.0) -> 600 raw -> 30.0 bar - val clutchPhysical = AttrConverter.toPhysicalValue(600, 0.05, 0.0) - assertThat(clutchPhysical).isEqualTo(30.0) - val clutchRaw = AttrConverter.toRawValue(30.0, 0.05, 0.0) - assertThat(clutchRaw).isEqualTo(600.0) - - // 케이스 5: 0 나눗셈 방어 - val divZero = AttrConverter.toRawValue(100.0, 0.0, 10.0) - assertThat(divZero).isEqualTo(0.0) - } - - // ========================================================================= - // 8. 패킷 오프셋 기반 unpackPhysicalValue 통합 검증 - // ========================================================================= - - @Test - @DisplayName("복합 바이트 버퍼에서 오프셋별 unpackPhysicalValue 연속 파싱 검증") - fun testUnpackPhysicalValueFromCombinedBuffer() { - // 가상 패킷 페이로드: - // [0..3]: EngineRPM (ATTR 4, cvsA 1.0, cvsB 0.0) = 3500 (0x00000DAC -> AC 0D 00 00) - // [4..5]: CoolantTemp (ATTR 2, cvsA 0.1, cvsB -40.0) = 1350 (0x0546 -> 46 05) -> 95.0 °C - // [6]: ThrottlePos (ATTR 0, cvsA 0.5, cvsB 0.0) = 120 (0x78) -> 60.0 % - // [7..8]: IntakePressure (ATTR 2, cvsA 0.1, cvsB 0.0) = 1850 (0x073A -> 3A 07) -> 185.0 kPa - val buffer = byteArrayOf( - 0xAC.toByte(), 0x0D, 0x00, 0x00, // 3500 - 0x46, 0x05, // 1350 - 0x78, // 120 - 0x3A, 0x07 // 1850 - ) - - val rpm = AttrConverter.unpackPhysicalValue(buffer, 0, MdbAttrType.ATTR_4_UINT32, 1.0, 0.0) - assertThat(rpm).isEqualTo(3500.0) - - val temp = AttrConverter.unpackPhysicalValue(buffer, 4, MdbAttrType.ATTR_2_UINT16, 0.1, -40.0) - assertThat(temp).isEqualTo(95.0) - - val throttle = AttrConverter.unpackPhysicalValue(buffer, 6, MdbAttrType.ATTR_0_UINT8, 0.5, 0.0) - assertThat(throttle).isEqualTo(60.0) - - val pressure = AttrConverter.unpackPhysicalValue(buffer, 7, MdbAttrType.ATTR_2_UINT16, 0.1, 0.0) - assertThat(pressure).isEqualTo(185.0) - } - - // ========================================================================= - // 9. 버퍼 언더플로우 / 경계 예외 방어 검증 - // ========================================================================= - - @Test - @DisplayName("버퍼 크기 부족 시 IllegalArgumentException 예외 발생 검증") - fun testBufferUnderflowThrowsException() { - val shortBuffer = byteArrayOf(0x01, 0x02) - - // ATTR 4는 4바이트 필요하지만 버퍼는 2바이트 -> 예외 - assertThrows { - AttrConverter.unpackRawValue(shortBuffer, 0, MdbAttrType.ATTR_4_UINT32) - } - - // 오프셋 초과 -> 예외 - assertThrows { - AttrConverter.unpackRawValue(shortBuffer, 2, MdbAttrType.ATTR_2_UINT16) - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/CalibrationEditorTest.kt b/Android/app/src/test/java/com/dct/app/tests/CalibrationEditorTest.kt deleted file mode 100644 index b307695..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/CalibrationEditorTest.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.DctResult -import com.dct.app.core.common.DefaultDispatcherProvider -import com.dct.app.domain.model.mdb.* -import com.dct.app.domain.repository.MdbRepository -import com.dct.app.presentation.calibration.CalibrationEditorMode -import com.dct.app.presentation.calibration.CalibrationViewModel -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -@DisplayName("M5 - CalibrationViewModel 캘리브레이션 튜너 및 맵 편집기 단위 테스트") -class CalibrationEditorTest { - - private lateinit var mdbRepository: MdbRepository - private lateinit var dispatchers: DefaultDispatcherProvider - private lateinit var viewModel: CalibrationViewModel - - private lateinit var sampleConstant: MdbConstant - private lateinit var sampleMap: CompensationMap - - @BeforeEach - fun setUp() { - mdbRepository = mockk(relaxed = true) - dispatchers = DefaultDispatcherProvider() - viewModel = CalibrationViewModel(mdbRepository, dispatchers) - - sampleConstant = MdbConstant( - idx = 1, - name = "K_ENG_IDLE_TARGET", - data = 750.0, - addr = 0x1020L, - min = 600.0, - max = 1200.0, - attr = MdbAttrType.ATTR_2_UINT16, - description = "엔진 아이들 목표 속도" - ) - - val points = listOf( - MapPoint(idx = 1, bkp = 1000, data = 100, phy = 25.0), - MapPoint(idx = 2, bkp = 2000, data = 200, phy = 50.0), - MapPoint(idx = 3, bkp = 3000, data = 300, phy = 75.0) - ) - sampleMap = CompensationMap( - type = CompensationMapType.TYPE_A, - idx = 1, - yName = "TB_TQ_OFFSET", - yAddr = 0x2050L, - yAttr = MdbAttrType.ATTR_2_UINT16, - yMin = 0.0, - yMax = 100.0, - xName = "RPM_AXIS", - xAddr = 0x2060L, - xAttr = MdbAttrType.ATTR_2_UINT16, - cvsA = 0.25, - cvsB = 0.0, - description = "토크 보정 맵", - points = points - ) - } - - @Test - @DisplayName("상수 선택 시 초기 상태가 정상 구성되어야 한다") - fun testSelectConstantInitialState() { - viewModel.selectConstant(sampleConstant) - - val state = viewModel.uiState.value - assertEquals(CalibrationEditorMode.CONSTANT, state.mode) - assertNotNull(state.constantState) - val constState = state.constantState!! - assertEquals(750.0, constState.currentValue) - assertEquals("750.0", constState.textValue) - assertTrue(constState.isValid) - assertFalse(constState.isDirty) - } - - @Test - @DisplayName("상수 입력값 변경 시 정상 범위이면 isValid=true 및 dirty=true가 되어야 한다") - fun testConstantValidTextChange() { - viewModel.selectConstant(sampleConstant) - viewModel.onConstantTextChanged("850.0") - - val state = viewModel.uiState.value.constantState!! - assertEquals(850.0, state.currentValue) - assertTrue(state.isValid) - assertTrue(state.isDirty) - assertNull(state.errorMessage) - } - - @Test - @DisplayName("상수 입력값이 Min/Max 범위를 벗어나면 isValid=false 및 에러 메시지가 설정되어야 한다") - fun testConstantOutOfRangeValidation() { - viewModel.selectConstant(sampleConstant) - - // 1. Max(1200) 초과 - viewModel.onConstantTextChanged("1500.0") - val state1 = viewModel.uiState.value.constantState!! - assertFalse(state1.isValid) - assertNotNull(state1.errorMessage) - - // 2. Min(600) 미만 - viewModel.onConstantTextChanged("400.0") - val state2 = viewModel.uiState.value.constantState!! - assertFalse(state2.isValid) - assertNotNull(state2.errorMessage) - - // 3. 비정상 텍스트 - viewModel.onConstantTextChanged("abc") - val state3 = viewModel.uiState.value.constantState!! - assertFalse(state3.isValid) - } - - @Test - @DisplayName("상수 슬라이더 이동 시 값이 소수점 2자리로 정밀 갱신되어야 한다") - fun testConstantSliderChange() { - viewModel.selectConstant(sampleConstant) - viewModel.onConstantSliderChanged(825.456) - - val state = viewModel.uiState.value.constantState!! - assertEquals(825.46, state.currentValue, 0.01) - assertTrue(state.isDirty) - assertTrue(state.isValid) - } - - @Test - @DisplayName("상수 초기화(resetConstant) 시 원래 값으로 복원되어야 한다") - fun testResetConstant() { - viewModel.selectConstant(sampleConstant) - viewModel.onConstantTextChanged("900.0") - assertTrue(viewModel.uiState.value.constantState!!.isDirty) - - viewModel.resetConstant() - val state = viewModel.uiState.value.constantState!! - assertEquals(750.0, state.currentValue) - assertFalse(state.isDirty) - } - - @Test - @DisplayName("상수 Live Commit 시 MdbRepository.updateConstant가 호출되고 성공 피드백이 갱신되어야 한다") - fun testCommitConstant() = runTest { - coEvery { mdbRepository.updateConstant("K_ENG_IDLE_TARGET", 850.0) } returns DctResult.Success(Unit) - - viewModel.selectConstant(sampleConstant) - viewModel.onConstantTextChanged("850.0") - viewModel.commitConstant() - - coVerify { mdbRepository.updateConstant("K_ENG_IDLE_TARGET", 850.0) } - val state = viewModel.uiState.value - assertTrue(state.isSuccessFeedback) - assertNotNull(state.statusMessage) - } - - @Test - @DisplayName("보정 맵 선택 시 포인트 목록이 복사되어 초기화되어야 한다") - fun testSelectCompensationMap() { - viewModel.selectCompensationMap(sampleMap) - - val state = viewModel.uiState.value - assertEquals(CalibrationEditorMode.COMPENSATION_MAP, state.mode) - assertNotNull(state.mapState) - val mapState = state.mapState!! - assertEquals(3, mapState.editedPoints.size) - assertFalse(mapState.isDirty) - } - - @Test - @DisplayName("보정 맵에서 Raw Data 수정 시 Y=AX+B 공식에 따라 Physical 값이 자동 계산되어야 한다") - fun testUpdateMapPointRawData() { - viewModel.selectCompensationMap(sampleMap) - - // cvsA = 0.25, cvsB = 0.0 -> Raw=400 입력 시 Phy = 400 * 0.25 + 0.0 = 100.0 - viewModel.updateMapPoint(index = 0, newBkp = 1000, newRawData = 400, newPhy = null) - - val mapState = viewModel.uiState.value.mapState!! - val pt0 = mapState.editedPoints[0] - assertEquals(400, pt0.data) - assertEquals(100.0, pt0.phy, 0.001) - assertTrue(mapState.isDirty) - } - - @Test - @DisplayName("보정 맵에서 Physical 값 수정 시 Raw Data가 역산되어야 한다") - fun testUpdateMapPointPhysicalValue() { - viewModel.selectCompensationMap(sampleMap) - - // cvsA = 0.25, cvsB = 0.0 -> Phy=60.0 입력 시 Raw = (60.0 - 0.0) / 0.25 = 240 - viewModel.updateMapPoint(index = 1, newBkp = 2000, newRawData = null, newPhy = 60.0) - - val mapState = viewModel.uiState.value.mapState!! - val pt1 = mapState.editedPoints[1] - assertEquals(240, pt1.data) - assertEquals(60.0, pt1.phy, 0.001) - assertTrue(mapState.isDirty) - } - - @Test - @DisplayName("보정 맵 포인트 추가 및 삭제가 정상 동작해야 한다 (최소 2개 보장)") - fun testAddAndDeleteMapPoint() { - viewModel.selectCompensationMap(sampleMap) - assertEquals(3, viewModel.uiState.value.mapState!!.editedPoints.size) - - // 1. 포인트 추가 - viewModel.addMapPoint() - assertEquals(4, viewModel.uiState.value.mapState!!.editedPoints.size) - - // 2. 포인트 삭제 - viewModel.deleteMapPoint(3) - assertEquals(3, viewModel.uiState.value.mapState!!.editedPoints.size) - - viewModel.deleteMapPoint(2) - assertEquals(2, viewModel.uiState.value.mapState!!.editedPoints.size) - - // 최소 2개 남아있을 때 삭제 시도 -> 삭제되지 않음 - viewModel.deleteMapPoint(1) - assertEquals(2, viewModel.uiState.value.mapState!!.editedPoints.size) - } - - @Test - @DisplayName("보정 맵 Live Commit 시 MdbRepository로 포인트 목록 및 메타데이터가 정상 저장되어야 한다") - fun testCommitCompensationMap() = runTest { - coEvery { mdbRepository.updateCompensationMap(any(), any()) } returns DctResult.Success(Unit) - coEvery { mdbRepository.updateCompensationMetadata(any(), any(), any(), any(), any(), any(), any()) } returns DctResult.Success(Unit) - - viewModel.selectCompensationMap(sampleMap) - viewModel.addMapPoint() - viewModel.commitMap() - - coVerify { mdbRepository.updateCompensationMap("TB_TQ_OFFSET", any()) } - coVerify { mdbRepository.updateCompensationMetadata(true, "TB_TQ_OFFSET", any(), any(), any(), any(), any()) } - assertTrue(viewModel.uiState.value.isSuccessFeedback) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/CanDbcParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/CanDbcParserTest.kt deleted file mode 100644 index 83a5c5a..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/CanDbcParserTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.util.TextStreamDecoder -import com.dct.app.data.parser.CanDbcParser -import com.dct.app.domain.model.mdb.CanMultiplexRole -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test -import java.io.ByteArrayInputStream -import java.nio.charset.Charset - -class CanDbcParserTest { - - @Test - fun testCanDbcParsingUtf8() { - val sampleDbc = """ - VERSION "" - BO_ 512 ALDC_Status: 8 ALDC - SG_ ALDC_SysTempVal : 0|8@1+ (1,-40) [-40|150] "℃" Vector__XXX - SG_ ALDC_PwrMonVal : 8|16@1+ (0.1,0) [0|1000] "W" Vector__XXX - SG_ ALDC_EwpRpmCtrlVal : 24|16@1+ (1,0) [0|10000] "rpm" Vector__XXX - """.trimIndent() - - val inputStream = ByteArrayInputStream(sampleDbc.toByteArray(Charsets.UTF_8)) - val signals = CanDbcParser.parseDbc(inputStream) - - assertEquals(3, signals.size) - val tempSig = signals.find { it.name == "ALDC_SysTempVal" } - assertNotNull(tempSig) - assertEquals("℃", tempSig!!.unit) - assertEquals(-40.0, tempSig.cvsB, 0.001) - assertEquals("ALDC_Status", tempSig.messageName) - assertEquals(512L, tempSig.messageId) - } - - @Test - fun testCanDbcParsingEucKrEncodedSpecialUnits() { - // EUC-KR / CP949 인코딩으로 저장된 DBC 파일 시뮬레이션 - val rawDbc = """ - VERSION "" - BO_ 512 ALDC_Status: 8 ALDC - SG_ ALDC_SysTempVal : 0|8@1+ (1,-40) [-40|150] "℃" Vector__XXX - SG_ ALDC_MotorTemp : 8|8@1+ (1,-40) [-40|150] "°C" Vector__XXX - SG_ ALDC_PwrMonVal : 16|16@1+ (0.1,0) [0|1000] "W" Vector__XXX - """.trimIndent() - - val eucKrCharset = Charset.forName("EUC-KR") - val bytes = rawDbc.toByteArray(eucKrCharset) - val inputStream = ByteArrayInputStream(bytes) - - val signals = CanDbcParser.parseDbc(inputStream) - - assertEquals(3, signals.size) - val tempSig = signals.find { it.name == "ALDC_SysTempVal" } - assertNotNull(tempSig) - assertEquals("℃", tempSig!!.unit) - - val motorTempSig = signals.find { it.name == "ALDC_MotorTemp" } - assertNotNull(motorTempSig) - assertEquals("°C", motorTempSig!!.unit) - } - - @Test - fun testCanDbcParsingWindows1252DegreeUnits() { - // Windows-1252 / ISO-8859-1 (0xB0 0x43 = °C) - val rawDbc = "BO_ 512 Test: 8 XXX\n SG_ Temp : 0|8@1+ (1,0) [0|100] \"\u00B0C\" XXX\n" - val winCharset = Charset.forName("windows-1252") - val bytes = rawDbc.toByteArray(winCharset) - val inputStream = ByteArrayInputStream(bytes) - - val signals = CanDbcParser.parseDbc(inputStream) - - assertEquals(1, signals.size) - assertEquals("°C", signals[0].unit) - } - - @Test - fun testTextStreamDecoderSanitizeUnit() { - assertEquals("℃", TextStreamDecoder.sanitizeUnit("\uFFFD\uFFFD")) - assertEquals("℃", TextStreamDecoder.sanitizeUnit("'C")) - assertEquals("℃", TextStreamDecoder.sanitizeUnit("deg C")) - assertEquals("℃", TextStreamDecoder.sanitizeUnit("degC")) - assertEquals("°", TextStreamDecoder.sanitizeUnit("deg")) - assertEquals("rpm", TextStreamDecoder.sanitizeUnit("rpm")) - assertEquals("V", TextStreamDecoder.sanitizeUnit("V")) - assertEquals("W", TextStreamDecoder.sanitizeUnit("W")) - } - - @Test - fun `기본 DBC 멀티플렉서 M과 m값을 파싱한다`() { - val sampleDbc = """ - BO_ 291 MuxMessage: 8 ECU - SG_ Mode M : 0|4@1+ (1,0) [0|15] "" ECU - SG_ Value0 m0 : 8|8@1+ (1,0) [0|255] "" ECU - SG_ Value7 m7 : 8|8@1+ (1,0) [0|255] "" ECU - SG_ Always : 16|8@1+ (1,0) [0|255] "" ECU - """.trimIndent() - - val signals = CanDbcParser.parseDbc(ByteArrayInputStream(sampleDbc.toByteArray())) - - assertEquals(4, signals.size) - assertEquals(CanMultiplexRole.MULTIPLEXOR, signals[0].multiplexRole) - assertEquals(CanMultiplexRole.MULTIPLEXED, signals[1].multiplexRole) - assertEquals(0L, signals[1].multiplexValue) - assertEquals(7L, signals[2].multiplexValue) - assertEquals(CanMultiplexRole.NONE, signals[3].multiplexRole) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt b/Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt deleted file mode 100644 index e6847b7..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.theme.PcColorRef -import com.dct.app.data.logger.CsvMeasurementLogger -import com.dct.app.data.logger.Mf4MeasurementLogger -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.formula.FormulaDefinition -import com.dct.app.domain.model.mdb.CanSignalKey -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.SignalDefinition -import com.dct.app.domain.model.project.DpcProject -import com.dct.app.domain.model.project.DpcSensorModuleConfig -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.presentation.main.MainUiState -import com.dct.app.presentation.main.MainViewModel -import com.dct.app.presentation.main.MeasurementRecordingState -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.UUID -import java.util.zip.Inflater - -@Suppress("UNCHECKED_CAST") -class CanPageRecordingTest { - @TempDir lateinit var directory: File - private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } - private val a = SignalDefinition(0, 1, "CanA", MdbAttrType.ATTR_0_UINT8, 2.0, -10.0, "V", 0L, 0.0, 255.0, - messageId = 0x101, startBit = 0, bitLength = 8) - private val b = a.copy(idx = 2, name = "CanB", attr = MdbAttrType.ATTR_3_INT16, messageId = 0x102, - bitLength = 16, cvsA = .1, cvsB = -40.0) - - private inner class Fixture(sensorAndFormula: Boolean = false) : AutoCloseable { - val owner = MainViewModelTest().also { it.setUp() } - val vm = field(owner, "viewModel").get(owner) as MainViewModel - val process = MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, - Double::class.javaPrimitiveType).also { it.isAccessible = true } - val csv get() = (field(vm, "activeCsvLoggers").get(vm) as List).single { - it.outputFile!!.name.endsWith("_dbc.csv") } - val mf4 get() = (field(vm, "activeMf4Loggers").get(vm) as List).single { - it.outputFile!!.name.endsWith("_dbc.mf4") } - var start = 0L - var sequence = 0L - init { - vm.setCurrentProject(DpcProject(projectFileName = "PAGES.dpc", canDbcFileName = "fixture.dbc", - measuredFilesFolder = directory.resolve(UUID.randomUUID().toString()).absolutePath, - writeCsv = true, writeMf4 = true, csvWithMilliseconds = true, - sensorModules = listOf(DpcSensorModuleConfig(0, isUsed = sensorAndFormula)))) - (field(vm, "_canSignals").get(vm) as MutableStateFlow>).value = listOf(a, b) - val ui = field(vm, "_uiState").get(vm) as MutableStateFlow - ui.value = ui.value.copy(isConnected = true, isMeasuring = true) - if (sensorAndFormula) vm.addFormula(FormulaDefinition(name = "F", expression = "CanA*2")) - vm.chartRenderer.bindSignal(CanSignalKey.stable(a), a.name, AxisPosition.LEFT_0, PcColorRef.DEFAULT_DASHBOARD) - vm.chartRenderer.bindSignal(CanSignalKey.stable(b), b.name, AxisPosition.LEFT_0, PcColorRef.DEFAULT_DASHBOARD) - startRecording() - } - fun startRecording() { - vm.startMeasurementRecording() - assertEquals(MeasurementRecordingState.RECORDING, vm.uiState.value.recordingState) - start = vm.uiState.value.recordingStartedAtMillis!! - } - fun can(page: Int, total: Int, offset: Long, vararg frames: Pair, notReceived: Boolean = false) { - val payload = byteArrayOf(2, page.toByte(), total.toByte(), frames.size.toByte()) + frames.fold(byteArrayOf()) { out, (id, data) -> - out + byteArrayOf((id ushr 8).toByte(), id.toByte(), (data.size or if (notReceived) 128 else 0).toByte()) + data - } - receive(DctPacket(0xA3.toByte(), payload, receivedAtMillis = start + offset, ingressSequence = ++sequence)) - } - fun receive(packet: DctPacket) = process.invoke(vm, packet, packet.receivedAtMillis / 1000.0) - fun stop() = vm.stopMeasurementRecording() - override fun close() { - stop() - vm.scope.cancel() - vm.chartRenderer.stop() - owner.tearDown() - } - } - - private fun csvRows(file: File) = file.readLines().drop(7).filter { it.isNotBlank() } - .map { line -> line.split(',').map(String::trim) } - private fun binary(file: File) = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN) - private fun records(file: File): ByteBuffer { - val data = binary(file) - val recordSize = data.getInt(232 + 96) - val hl = data.getLong(168 + 40).toInt() - val dl = data.getLong(hl + 24).toInt() - if (dl == 0) return ByteBuffer.allocate(0) - val dz = data.getLong(dl + 32).toInt() - val rawSize = data.getLong(dz + 32).toInt() - val compressedSize = data.getLong(dz + 40).toInt() - val transposed = ByteArray(rawSize) - val inflater = Inflater() - try { - inflater.setInput(data.array(), dz + 48, compressedSize) - assertEquals(rawSize, inflater.inflate(transposed)) - assertTrue(inflater.finished()) - } finally { inflater.end() } - val count = rawSize / recordSize - val raw = ByteArray(rawSize) - for (column in 0 until recordSize) for (row in 0 until count) { - raw[row * recordSize + column] = transposed[column * count + row] - } - return ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN) - } - - @Test fun fourPagesUpdateGraphsImmediatelyButWriteOneFullSnapshotAtTheLastPage() { - Fixture().use { f -> - val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!! - f.can(1, 4, 100, 0x101 to byteArrayOf(12)) - f.can(2, 4, 200, 0x102 to byteArrayOf(0xF4.toByte(), 1)) // Raw 500. - f.can(3, 4, 300, 0x101 to byteArrayOf(20)) - assertEquals(0L, f.vm.uiState.value.recordingSampleCount) - assertEquals(2, f.vm.chartRenderer.seriesListFlow.value.first { it.signalName == "CanA" }.buffer.size) - assertEquals(30.0, f.vm.liveTelemetryValues.value["CanA"]) - f.can(4, 4, 400, 0x102 to byteArrayOf(0x58, 2)) // Raw 600. - assertEquals(1L, f.vm.uiState.value.recordingSampleCount) - f.stop() - val rows = csvRows(csv) - assertEquals(1, rows.size) - assertEquals("0", rows.single()[1]) - assertEquals(30.0, rows.single()[2].toDouble()) - assertEquals(20.0, rows.single()[3].toDouble()) - assertEquals(1L, binary(mf4).getLong(232 + 80)) - val raw = records(mf4) - assertEquals(.4, raw.double, 0.000001) - assertEquals(20, raw.get().toInt() and 255) - assertEquals(600, raw.short.toInt()) - assertFalse(raw.hasRemaining()) - } - } - - @ParameterizedTest @ValueSource(ints = [0, 1, 2]) - fun finalPageWithNoNewDecodedSignalsStillWritesEarlierPageValues(lastKind: Int) { - Fixture().use { f -> - val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!! - f.can(1, 2, 100, 0x101 to byteArrayOf(12)) - assertEquals(0L, f.vm.uiState.value.recordingSampleCount) - when (lastKind) { - 0 -> f.can(2, 2, 200) - 1 -> f.can(2, 2, 200, 0x777 to byteArrayOf(1)) - else -> f.can(2, 2, 200, 0x102 to byteArrayOf(0, 0), notReceived = true) - } - f.stop() - assertEquals(14.0, csvRows(csv).single()[2].toDouble()) - assertEquals("No value", csvRows(csv).single()[3]) - val raw = records(mf4) - assertEquals(.2, raw.double, 0.000001) - assertEquals(12, raw.get().toInt() and 255) - assertEquals(0, raw.short.toInt()) - assertEquals(1L, f.vm.uiState.value.recordingSampleCount) - } - } - - @ParameterizedTest @ValueSource(ints = [0, 1]) - fun equalSinglePageNumbersRecordEveryPacket(page: Int) { - Fixture().use { f -> - val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!! - f.can(page, page, 100, 0x101 to byteArrayOf(12)) - f.can(page, page, 200, 0x101 to byteArrayOf(20)) - f.stop() - assertEquals(2, csvRows(csv).size) - assertEquals(2L, binary(mf4).getLong(232 + 80)) - } - } - - @Test fun interleavedSensorAndFormulaRecordingDoesNotWriteAnExtraCanRow() { - Fixture(sensorAndFormula = true).use { f -> - val loggers = field(f.vm, "activeCsvLoggers").get(f.vm) as List - val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!! - val formula = loggers.single { it.outputFile!!.name.endsWith("_formula.csv") }.outputFile!! - val sensor = loggers.single { it.outputFile!!.name.endsWith("_sm.csv") }.outputFile!! - f.can(1, 2, 100, 0x101 to byteArrayOf(12)) - f.receive(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 25, 0, 0), receivedAtMillis = f.start + 150)) - f.can(2, 2, 200, 0x102 to byteArrayOf(0xF4.toByte(), 1)) - f.stop() - assertEquals(1, csvRows(csv).size) - assertEquals(1L, binary(mf4).getLong(232 + 80)) - assertEquals(1, csvRows(formula).size) - assertEquals(28.0, csvRows(formula).single()[2].toDouble()) - assertEquals(1, csvRows(sensor).size) - assertEquals(25.0, csvRows(sensor).single()[2].toDouble()) - assertEquals(3L, f.vm.uiState.value.recordingSampleCount) - } - } - - @Test fun stoppingBeforeTheLastPageAndRestartingKeepsFilesAndCountersSeparate() { - Fixture().use { f -> - val firstCsv = f.csv.outputFile!!; val firstMf4 = f.mf4.outputFile!! - f.can(1, 2, 100, 0x101 to byteArrayOf(12)) - f.stop() - assertTrue(csvRows(firstCsv).isEmpty()) - assertEquals(0L, binary(firstMf4).getLong(232 + 80)) - f.startRecording() - val secondCsv = f.csv.outputFile!!; val secondMf4 = f.mf4.outputFile!! - f.can(2, 2, 200, 0x102 to byteArrayOf(0xF4.toByte(), 1)) - f.stop() - assertEquals(1, csvRows(secondCsv).size) - assertEquals(14.0, csvRows(secondCsv).single()[2].toDouble()) - assertEquals(1L, binary(secondMf4).getLong(232 + 80)) - val raw = records(secondMf4) - raw.double - assertEquals(12, raw.get().toInt() and 255) - assertEquals(500, raw.short.toInt()) - assertEquals(1L, f.vm.uiState.value.recordingSampleCount) - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/CanSignalKeyTest.kt b/Android/app/src/test/java/com/dct/app/tests/CanSignalKeyTest.kt deleted file mode 100644 index 6bde642..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/CanSignalKeyTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.mdb.CanSignalKey -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.SignalDefinition -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertSame -import org.junit.Test - -class CanSignalKeyTest { - private fun signal(idx: Int, messageId: Long, name: String) = SignalDefinition( - reclid = 0, - idx = idx, - name = name, - attr = MdbAttrType.ATTR_0_UINT8, - cvsA = 1.0, - cvsB = 0.0, - unit = "", - color = 0L, - min = 0.0, - max = 255.0, - messageId = messageId - ) - - @Test - fun stableKeyDoesNotDependOnParserIndex() { - val first = signal(1, 0x123, "VehicleSpeed") - val reparsed = signal(999, 0x123, "VehicleSpeed") - - assertEquals(CanSignalKey.stable(first), CanSignalKey.stable(reparsed)) - assertNotEquals(CanSignalKey.legacy(first), CanSignalKey.legacy(reparsed)) - } - - @Test - fun findsStableAndLegacyProjectKeys() { - val target = signal(7, 0x456, "MotorTorque") - val signals = listOf(target) - - assertSame(target, CanSignalKey.find("can_1110_MotorTorque", signals)) - assertSame(target, CanSignalKey.find("can_7", signals)) - assertSame(target, CanSignalKey.find("can_7_MotorTorque", signals)) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt deleted file mode 100644 index 2edb55a..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt +++ /dev/null @@ -1,253 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.chart.TimeWindowPreset -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class ChartContinuousScrollTest { - private val presets = TimeWindowPreset.entries.filter { it.tickIntervalSec != null } - private fun TestScope.renderer() = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))).apply { - bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF0000L) - } - private fun ChartBatchRenderer.sample(time: Double) = addSampleSec("s", time, 1.0) - - @Test fun allFixedPresetsHoldRangeBetweenBoundaryJumps() = runTest { - for (preset in presets) { - val r = renderer() - r.sample(10000.0 - preset.displaySpanSec) - r.sample(10000.0) - r.setTimeWindow(preset) - for (time in listOf(10000.016, 10000.033, 10000.1, 10001.0, 10002.0)) { - r.sample(time) - val frame = r.buildSnapshot(600) - assertEquals(10000.016 + preset.tickIntervalSec!!, frame.timeMaxSec, 1e-8) - assertEquals(preset.displaySpanSec, frame.timeMaxSec - frame.timeMinSec, 1e-8) - assertEquals(preset, frame.xAxisPreset) - assertTrue(frame.isXAxisAuto) - } - } - } - - @Test fun clearRetainsInitialSixIntervalsThenJumpsByTick() = runTest { - for (preset in presets) { - val r = renderer() - r.setTimeWindow(preset) - r.clearAllData() - r.sample(10000.0) - val end = 10000.0 + preset.resetSpanSec - assertEquals(end, r.buildSnapshot(600).timeMaxSec) - r.sample(end - .1) - assertEquals(end, r.buildSnapshot(600).timeMaxSec) - r.sample(end) - assertEquals(end + preset.tickIntervalSec!!, r.buildSnapshot(600).timeMaxSec) - r.sample(end + .016) - val frame = r.buildSnapshot(600) - assertEquals(end + preset.tickIntervalSec!!, frame.timeMaxSec, 1e-8) - assertEquals(preset.resetSpanSec, frame.timeMaxSec - frame.timeMinSec, 1e-8) - } - } - - @Test fun clearSamplesOnlyKeepsGraphItemsAndOptions() = runTest { - val r = renderer() - r.setTimeWindow(TimeWindowPreset.SEC_10) - r.setYAxisRange(-5.0, 25.0) - r.setLegendPosition(2) - r.setSeriesColor("s", 0x00FF00L) - r.setSeriesThickness("s", 4.0f) - r.addSampleSec("s", 1000.0, 2.0) - r.addSampleSec("s", 1010.0, 4.0) - val before = r.exportWorkspaceState() - - r.clearSamplesOnly() - - val series = r.seriesListFlow.value.single() - val after = r.exportWorkspaceState() - assertEquals(1, r.seriesListFlow.value.size, "Signal item must remain bound") - assertEquals(0, series.buffer.size, "Only samples must be removed") - assertEquals(before.axes, after.axes) - assertEquals(before.timeWindowSec, after.timeWindowSec) - assertEquals(before.timeWindowPreset, after.timeWindowPreset) - assertEquals(before.isAutoTimeWindow, after.isAutoTimeWindow) - assertEquals(before.isXAxisAuto, after.isXAxisAuto) - assertEquals(before.isYAxisAuto, after.isYAxisAuto) - assertEquals(before.legendPosition, after.legendPosition) - assertEquals(0x00FF00L, series.color) - assertEquals(4.0f, series.lineWidthDp) - } - - @Test fun firstBatchLargerThanWindowDoesNotAllocateAnExtraBlankTick() = runTest { - val r = renderer() - r.setTimeWindow(TimeWindowPreset.SEC_10) - r.sample(1000.0) - r.sample(1500.0) - val frame = r.buildSnapshot(600) - assertEquals(1500.0, frame.timeMaxSec) - assertEquals(1460.0, frame.timeMinSec) - } - - @Test fun holdAccumulatesFromFirstSampleAndKeepsWholeRecordingVisible() = runTest { - val r = renderer() - r.setTimeWindow(TimeWindowPreset.HOLD) - r.sample(1000.0) - val initial = r.buildSnapshot(600) - assertEquals(1000.0, initial.timeMinSec, 1e-8) - assertEquals(1060.0, initial.timeMaxSec, 1e-8) - assertFalse(initial.isXAxisAuto) - assertEquals(TimeWindowPreset.HOLD, initial.xAxisPreset) - - r.sample(1010.0) - val withinInitialSpan = r.buildSnapshot(600) - assertEquals(1000.0, withinInitialSpan.timeMinSec, 1e-8) - assertEquals(1060.0, withinInitialSpan.timeMaxSec, 1e-8) - - r.sample(1100.0) - val accumulated = r.buildSnapshot(600) - assertEquals(1000.0, accumulated.timeMinSec, 1e-8) - assertEquals(1110.0, accumulated.timeMaxSec, 1e-8) - - r.sample(2000.0) - val later = r.buildSnapshot(600) - assertEquals(1000.0, later.timeMinSec, 1e-8) - assertEquals(2030.0, later.timeMaxSec, 1e-8) - - // Hold guarantees both endpoints stay visible; historical panning is - // available after explicitly switching to Manual/zooming. - r.panTimeWindow(-100.0) - val stillFit = r.buildSnapshot(600) - assertEquals(1000.0, stillFit.timeMinSec, 1e-8) - assertEquals(later.timeMaxSec, stillFit.timeMaxSec, 1e-8) - } - - @Test fun holdWaitsForEachBoundaryThenAddsOneCurrentTickInBothGraphLayouts() = runTest { - for (axis in listOf(AxisPosition.LEFT_0, AxisPosition.LEFT_1)) { - val r = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))) - r.bindSignal("s", "Signal", axis, 0xFF0000L) - r.setTimeWindow(TimeWindowPreset.HOLD) - r.sample(1000.0) - var before = r.buildSnapshot(600) - repeat(12) { - r.sample(before.timeMaxSec - 0.033) - assertEquals(before.timeMaxSec, r.buildSnapshot(600).timeMaxSec, 1e-8) - val step = com.dct.app.domain.model.chart.ChartAxisTicks.time( - before.timeMinSec, before.timeMaxSec, 600f, TimeWindowPreset.HOLD).interval.seconds - r.sample(before.timeMaxSec) - val expanded = r.buildSnapshot(600) - assertEquals(1000.0, expanded.timeMinSec, 1e-8) - assertEquals(before.timeMaxSec + step, expanded.timeMaxSec, 1e-8) - assertEquals(expanded.timeMaxSec, r.buildSnapshot(600).timeMaxSec, 1e-8, - "Rendering the same sample must not keep expanding the range") - r.sample(before.timeMaxSec + 0.033) - assertEquals(expanded.timeMaxSec, r.buildSnapshot(600).timeMaxSec, 1e-8) - before = expanded - } - } - } - - @Test fun holdUsesEachWindowsTickAndKeepsOptionsAndSamplesThroughClearAndRestore() = runTest { - val wide = renderer() - val narrow = renderer() - for (r in listOf(wide, narrow)) { - r.setTimeWindow(TimeWindowPreset.HOLD) - r.setYAxisRange(-10.0, 10.0) - r.setLegendPosition(2) - r.sample(1000.0) - } - val wideBefore = wide.buildSnapshot(1200) - val narrowBefore = narrow.buildSnapshot(300) - wide.sample(wideBefore.timeMaxSec) - narrow.sample(narrowBefore.timeMaxSec) - assertEquals(1065.0, wide.buildSnapshot(1200).timeMaxSec) - assertEquals(1090.0, narrow.buildSnapshot(300).timeMaxSec) - wide.sample(1060.1) - assertEquals(1065.0, wide.buildSnapshot(1200).timeMaxSec) - val state = wide.exportWorkspaceState() - val frame = wide.buildSnapshot(1200) - assertEquals(-10.0, frame.axesConfig.getValue(AxisPosition.LEFT_0).minVal) - assertEquals(3, wide.seriesListFlow.value.single().buffer.size) - val restored = renderer() - restored.restoreWorkspaceState(wide.seriesListFlow.value, state) - restored.sample(2000.0) - assertEquals(2060.0, restored.buildSnapshot(600).timeMaxSec) - assertEquals(TimeWindowPreset.HOLD, restored.buildSnapshot(600).xAxisPreset) - wide.clearSamplesOnly() - wide.sample(3000.0) - assertEquals(3000.0, wide.buildSnapshot(1200).timeMinSec) - assertEquals(3060.0, wide.buildSnapshot(1200).timeMaxSec) - assertEquals(state.axes, wide.exportWorkspaceState().axes) - assertEquals(state.legendPosition, wide.exportWorkspaceState().legendPosition) - } - - @Test fun selectingHoldWithExistingHistoryFitsEntireRecordingThenWaitsForBoundary() = runTest { - val r = renderer() - r.sample(1000.0) - r.sample(2000.0) - r.setTimeWindow(TimeWindowPreset.HOLD) - val initial = r.buildSnapshot(600) - assertEquals(1000.0, initial.timeMinSec) - assertTrue(initial.timeMaxSec > 2000.0) - r.sample(2000.033) - assertEquals(initial.timeMaxSec, r.buildSnapshot(600).timeMaxSec) - r.resetPan() - val reset = r.buildSnapshot(600) - assertEquals(1000.0, reset.timeMinSec) - assertTrue(reset.timeMaxSec >= 2000.033) - assertEquals(TimeWindowPreset.HOLD, reset.xAxisPreset) - } - - @Test fun renderFlowRollsAtFrameCadenceAndStopsMovingWhenSamplesStop() = runTest { - val r = renderer() - r.sample(960.0) - r.sample(1000.0) - r.setTimeWindow(TimeWindowPreset.SEC_10) - r.start(backgroundScope) - r.resume() - runCurrent() - for (time in listOf(1000.033, 1000.066, 1000.099)) { - r.sample(time) - advanceTimeBy(20) - runCurrent() - assertEquals(1010.033, r.renderSnapshotFlow.value.timeMaxSec, 1e-8) - } - advanceTimeBy(1000) - runCurrent() - assertEquals(1010.033, r.renderSnapshotFlow.value.timeMaxSec, 1e-8) - } - - @Test fun historyBrowsingStillFreezesAndDoubleTapReturnsToContinuousPresetTracking() = runTest { - val r = renderer() - r.sample(700.0) - r.sample(1000.0) - r.setTimeWindow(TimeWindowPreset.MIN_1) - r.panTimeWindow(-50.0) - r.sample(2000.0) - assertEquals(950.0, r.buildSnapshot(600).timeMaxSec) - r.resetPan() - r.sample(2000.033) - val frame = r.buildSnapshot(600) - assertEquals(2060.033, frame.timeMaxSec, 1e-8) - assertEquals(300.0, frame.timeMaxSec - frame.timeMinSec, 1e-8) - assertEquals(TimeWindowPreset.MIN_1, frame.xAxisPreset) - } - - @Test fun rollingWindowsDoNotChangeOtherWindowsInitialFillRange() = runTest { - val a = renderer() - val b = renderer() - a.sample(960.0) - a.sample(1000.0) - a.setTimeWindow(TimeWindowPreset.SEC_10) - b.setTimeWindow(TimeWindowPreset.MIN_1) - b.sample(1000.0) - val before = b.buildSnapshot(600) - a.sample(1000.5) - assertEquals(1010.5, a.buildSnapshot(600).timeMaxSec) - assertEquals(before.timeMaxSec, b.buildSnapshot(600).timeMaxSec) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt deleted file mode 100644 index d4e9e62..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.chart.InterpolationMode -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class ChartCursorStabilityTest { - private fun TestScope.renderer() = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))).apply { - bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF0000L) - addSampleSec("s", 0.0, 10.0) - addSampleSec("s", 2.0, 20.0) - setTimeWindow(2.0) - } - private fun ChartBatchRenderer.value(width: Int = 600) = buildSnapshot(width).seriesList.single().cursorValue - - @Test fun originalSamplesRemainStableAcrossDownsamplingScrollingAndZoom() = runTest { - val r = renderer() - for (i in 201..1000) r.addSampleSec("s", i / 100.0, (i % 7).toDouble()) - r.setTimeWindow(10.0) - r.setCrosshairTimeSec(2.035) - assertEquals(0.5, r.value()!!, 1e-10) - val oldPoints = r.buildSnapshot(2).seriesList.single().points - for (i in 1001..1030) r.addSampleSec("s", i / 100.0, (i % 7).toDouble()) - for (width in listOf(1, 2, 10, 1200)) assertEquals(0.5, r.value(width)!!, 1e-10) - assertNotEquals(oldPoints, r.buildSnapshot(2).seriesList.single().points) - r.panTimeWindow(-1.0) - r.zoomTimeWindow(2f, .5) - assertEquals(0.5, r.value()!!, 1e-10) - } - - @Test fun linearPulseAndExactTransitionUseCapturedOriginalNeighbors() = runTest { - val r = renderer() - r.setCrosshairTimeSec(1.0) - assertEquals(15.0, r.value()) - r.setGraphInterpolationMode(InterpolationMode.PWM_STEP) - assertEquals(10.0, r.value()) - r.setCrosshairTimeSec(2.0) - assertEquals(20.0, r.value()) - r.setSeriesInterpolationMode("s", InterpolationMode.LINEAR) - assertEquals(20.0, r.value()) - } - - @Test fun overwritesAndEvictionDoNotChangeSelectionButRetappingRefreshes() = runTest { - val r = renderer() - r.setCrosshairTimeSec(2.0) - r.addSampleSec("s", 2.0, 99.0) - assertEquals(20.0, r.value()) - r.setCrosshairTimeSec(2.0) - assertEquals(99.0, r.value()) - for (i in 3..100003) r.addSampleSec("s", i.toDouble(), -1.0) - assertEquals(99.0, r.value()) - r.setCrosshairTimeSec(2.0) - assertNull(r.value()) - } - - @Test fun missingDataDoesNotFollowLatestOrBecomeAValueUntilReselected() = runTest { - val r = renderer() - for (time in listOf(-1.0, 3.0, Double.NaN, Double.POSITIVE_INFINITY)) { - r.setCrosshairTimeSec(time) - assertNull(r.value()) - } - r.setCrosshairTimeSec(3.0) - r.addSampleSec("s", 3.0, 30.0) - assertNull(r.value()) - r.setCrosshairTimeSec(3.0) - assertEquals(30.0, r.value()) - } - - @Test fun resetClearAndRebindCannotReusePreviousSelection() = runTest { - val r = renderer() - r.setCrosshairTimeSec(1.0) - r.resetPan() - assertNull(r.value()) - r.setCrosshairTimeSec(1.0) - r.clearAllData() - assertNull(r.value()) - r.addSampleSec("s", 1.0, 30.0) - r.setCrosshairTimeSec(1.0) - r.unbindSignal("s") - r.bindSignal("s", "Replacement", AxisPosition.RIGHT_0, 0xFF00FFL) - r.addSampleSec("s", 1.0, 50.0) - assertNull(r.value()) - r.setCrosshairTimeSec(1.0) - assertEquals(50.0, r.value()) - r.setCrosshairTimeSec(null) - assertNull(r.renderSnapshotFlow.value.crosshairTimeSec) - assertNull(r.renderSnapshotFlow.value.seriesList.single().cursorValue) - } - - @Test fun liveFramesKeepValuesWhileScrollingAndMetadataStillUpdatesOnAllAxes() = runTest { - val r = renderer() - r.bindSignal("other", "Other", AxisPosition.RIGHT_0, 0xFF00FFL) - r.addSampleSec("other", 0.0, 100.0) - r.addSampleSec("other", 2.0, 200.0) - r.setSeriesVisible("other", false) - r.setCrosshairTimeSec(1.0) - r.start(backgroundScope) - r.resume() - runCurrent() - r.addSampleSec("s", 2.1, 999.0) - advanceTimeBy(20) - runCurrent() - val live = r.renderSnapshotFlow.value - assertTrue(live.timeMaxSec > 2.1, "Live edge reserves the next tick without changing sample values") - assertEquals(1.0, live.crosshairTimeSec) - assertEquals(listOf(15.0, 150.0), live.seriesList.map { it.cursorValue }) - r.setSeriesVisible("other", true) - r.setSeriesColor("other", 0xFFFFFFFFL) - val other = r.renderSnapshotFlow.value.seriesList.last() - assertTrue(other.isVisible) - assertEquals(0x00FFFFFFL, other.color) - assertEquals(150.0, other.cursorValue) - } - - @Test fun ringCaptureHandlesEmptyExactSingleAndWrappedBuffersWithoutClamping() { - val b = TimeSeriesRingBuffer(3) - assertTrue(b.captureAt(1.0).isEmpty()) - b.addSample(1.0, 10.0) - assertEquals(listOf(1.0 to 10.0), b.captureAt(1.0)) - assertTrue(b.captureAt(1.1).isEmpty()) - for (i in 2..4) b.addSample(i.toDouble(), i * 10.0) - val captured = b.captureAt(2.5) - assertEquals(listOf(2.0 to 20.0, 3.0 to 30.0), captured) - assertTrue(b.captureAt(1.0).isEmpty()) - assertTrue(b.captureAt(Double.NaN).isEmpty()) - b.clear() - assertEquals(listOf(2.0 to 20.0, 3.0 to 30.0), captured) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartCursorTooltipTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartCursorTooltipTest.kt deleted file mode 100644 index 217ea42..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChartCursorTooltipTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.chart.InterpolationMode -import com.dct.app.domain.model.chart.SeriesRenderData -import com.dct.app.presentation.chart.chartCursorTooltipRows -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class ChartCursorTooltipTest { - private fun series() = SeriesRenderData( - signalId = "sig_1_1", signalName = "Voltage", axisPosition = AxisPosition.LEFT_0, - color = 0x0000FFL, unit = "V", lineWidthDp = 1f, - interpolationMode = InterpolationMode.LINEAR, isVisible = true, - points = listOf(0.0 to 10.0, 2.0 to 20.0), - latestValue = 20.0, minVisibleValue = 10.0, maxVisibleValue = 20.0, cursorValue = 15.0 - ) - - @Test fun tooltipValuesStayOpaqueWhiteRegardlessOfSignalColor() { - for (color in listOf(0x000000L, 0x0000FFL, 0xFF0000L, 0x00FF00L, 0xFFFFFFFFL)) { - val row = chartCursorTooltipRows(listOf(series().copy(color = color))).single() - assertEquals(0xFFFFFFFF.toInt(), row.valueColorArgb) - } - } - - @Test fun linearShowsCursorValueAndUnitInsteadOfLatestValue() { - val row = chartCursorTooltipRows(listOf(series())).single() - assertEquals("Voltage", row.name) - assertEquals("15.00 V", row.value) - } - - @Test fun displayPointsCannotChangeCapturedCursorValue() { - val signal = series().copy(points = listOf(0.0 to 999.0, 2.0 to -999.0), latestValue = -999.0) - assertEquals("15.00 V", chartCursorTooltipRows(listOf(signal)).single().value) - assertEquals("15.00 V", chartCursorTooltipRows(listOf(signal.copy(points = emptyList()))).single().value) - } - - @Test fun missingAndNonFiniteValuesAreExplicitAndHiddenSignalsAreOmitted() { - assertEquals("N/A", chartCursorTooltipRows(listOf(series().copy(cursorValue = null))).single().value) - assertEquals("N/A", chartCursorTooltipRows(listOf(series().copy(cursorValue = Double.NaN))).single().value) - assertEquals("N/A", chartCursorTooltipRows(listOf(series().copy(cursorValue = Double.POSITIVE_INFINITY))).single().value) - assertTrue(chartCursorTooltipRows(listOf(series().copy(isVisible = false))).isEmpty()) - } - - @Test fun noUnitHasNoTrailingSpaceAndNegativeValuesArePreserved() { - val signal = series().copy(unit = "", cursorValue = -1.25) - assertEquals("-1.25", chartCursorTooltipRows(listOf(signal)).single().value) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartInitialFillTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartInitialFillTest.kt deleted file mode 100644 index ae597f3..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChartInitialFillTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.* -import com.dct.app.domain.model.project.DpcChartState -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class ChartInitialFillTest { - private fun renderer() = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { - bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF00FFL) - } - private fun ChartBatchRenderer.sample(t: Double) = addSampleSec("s", t, 1.0) - private fun ChartBatchRenderer.assertRange(left: Double, right: Double) { - val frame = buildSnapshot(600) - assertEquals(left, frame.timeMinSec, 1e-8) - assertEquals(right, frame.timeMaxSec, 1e-8) - } - @Test fun presetsFillFromFirstSampleRegardlessOfConfigurationOrder() { - for (preset in TimeWindowPreset.entries.filter { it.tickIntervalSec != null }) { - for (sampleFirst in listOf(true, false)) { - val r = renderer() - if (sampleFirst) r.sample(1000.0) - r.setTimeWindow(preset) - if (!sampleFirst) r.sample(1000.0) - val right = 1000.0 + preset.displaySpanSec - r.assertRange(1000.0, right) - r.sample(1001.0) - r.resetPan() - r.assertRange(1000.0, right) - r.sample(right) - r.assertRange(1000.0 + preset.tickIntervalSec!!, right + preset.tickIntervalSec!!) - r.sample(right + .033) - r.assertRange(1000.0 + preset.tickIntervalSec!!, right + preset.tickIntervalSec!!) - } - } - } - @Test fun doubleTapAfterZoomRestoresLeftAlignedRangeForBothAxisLayouts() { - for (multi in listOf(false, true)) { - val r = renderer() - if (multi) r.bindSignal("b", "Second", AxisPosition.RIGHT_0, 0xFFFFFFL) - r.sample(10.0) - r.sample(12.0) - r.setTimeWindow(TimeWindowPreset.SEC_10) - r.setYAxisRange(-5.0, 5.0) - r.zoomTimeWindow(2f, .5) - r.panTimeWindow(-1.0) - r.setCrosshairTimeSec(11.0) - repeat(3) { r.resetPan(); r.assertRange(10.0, 50.0) } - assertNull(r.renderSnapshotFlow.value.crosshairTimeSec) - assertEquals(-5.0, r.renderSnapshotFlow.value.axesConfig.getValue(AxisPosition.LEFT_0).minVal) - r.sample(51.0) - r.resetPan() - r.assertRange(11.0, 51.0) - r.sample(51.02) - r.assertRange(21.02, 61.02) - } - } - @Test fun customDurationAndRestoredFollowingScaleFillBeforeRolling() { - val r = renderer() - r.setTimeWindow(25.0) - r.sample(1000.0) - r.assertRange(1000.0, 1025.0) - r.sample(1002.0) - r.assertRange(1000.0, 1025.0) - val signals = r.seriesListFlow.value - val saved = r.exportWorkspaceState() - r.restoreWorkspaceState(signals, saved) - r.sample(2000.0) - r.resetPan() - r.assertRange(2000.0, 2025.0) - r.sample(2026.0) - val shifted = r.buildSnapshot(600) - assertTrue(shifted.timeMaxSec > 2026.0) - assertEquals(25.0, shifted.timeMaxSec - shifted.timeMinSec) - } - @Test fun restoredManualGetsInitialDataRangeThenRemainsFrozenUntilReset() { - val r = renderer() - r.restoreWorkspaceState(r.seriesListFlow.value, DpcChartState(timeWindowSec = 25.0, - timeWindowPreset = TimeWindowPreset.MANUAL, isXAxisAuto = false)) - r.sample(1000.0) - r.assertRange(1000.0, 1025.0) - r.sample(1100.0) - r.assertRange(1000.0, 1025.0) - r.resetPan() - r.assertRange(1075.0, 1100.0) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt deleted file mode 100644 index 06fb3dd..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt +++ /dev/null @@ -1,395 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.* -import com.dct.app.data.repository.DpcWorkspaceJson -import com.dct.app.domain.model.project.DpcChartState -import com.dct.app.domain.model.project.DpcPageState -import com.dct.app.domain.model.project.DpcWindowState -import com.dct.app.domain.model.project.DpcWorkspaceState -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class ChartNavigationTest { - private fun renderer() = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { - bindSignal("s", "Signal", AxisPosition.LEFT_0, 0x0000FFL) - addSampleSec("s", 1000.0, 1.0) - setTimeWindow(TimeWindowPreset.MIN_1) - } - private fun ChartBatchRenderer.sample(time: Double) = addSampleSec("s", time, 2.0) - private fun ChartBatchRenderer.snapshot() = buildSnapshot(600) - private fun ChartRenderSnapshot.span() = timeMaxSec - timeMinSec - - @Test fun doubleTapRestoresPreNavigationScalePresetAndLatestTrackingWithoutChangingYAxisOrData() { - val r = renderer() - r.setYAxisRange(-5.0, 5.0) - r.zoomTimeWindow(2f, .5) - r.zoomTimeWindow(2f, .2) - r.panTimeWindow(-20.0) - r.setCrosshairTimeSec(900.0) - r.sample(1400.0) - r.resetPan() - val restored = r.snapshot() - assertEquals(300.0, restored.span()) - assertEquals(1400.0, restored.timeMaxSec) - assertEquals(TimeWindowPreset.MIN_1, restored.xAxisPreset) - assertTrue(restored.isXAxisAuto) - assertNull(restored.crosshairTimeSec) - assertEquals(300.0, r.renderSnapshotFlow.value.span()) // Includes paused rendering. - assertEquals(-5.0, restored.axesConfig.getValue(AxisPosition.LEFT_0).minVal) - assertEquals(5.0, restored.axesConfig.getValue(AxisPosition.LEFT_0).maxVal) - assertEquals(0x0000FFL, r.seriesListFlow.value.single().color) - assertEquals(1000.0, r.seriesListFlow.value.single().buffer.getOldestTimestamp()) - r.sample(1401.0) - assertTrue(r.snapshot().timeMaxSec > 1400.0) - } - - @Test fun forwardPanCanPassTheOldRightEdgeAsNewSamplesArriveButNotTheLatestSample() { - val r = renderer() - r.clearAllData() - r.sample(700.0) - r.sample(1000.0) - r.setTimeWindow(TimeWindowPreset.MIN_1) - r.panTimeWindow(-50.0) - assertEquals(950.0, r.snapshot().timeMaxSec) - r.sample(1200.0) - r.panTimeWindow(100.0) - assertEquals(1050.0, r.snapshot().timeMaxSec) - r.panTimeWindow(10000.0) - assertEquals(1200.0, r.snapshot().timeMaxSec) - assertEquals(300.0, r.snapshot().span()) - assertTrue(r.snapshot().isXAxisAuto) - r.sample(1300.0) - assertEquals(1330.0, r.snapshot().timeMaxSec) - r.panTimeWindow(-30.0) - assertEquals(1300.0, r.snapshot().timeMaxSec) - } - - @Test fun forwardPanAfterZoomAlsoUsesCurrentLatestData() { - val r = renderer() - r.zoomTimeWindow(2f, .5) - r.panTimeWindow(-10.0) // Browse history before testing return to newer live data. - r.sample(2000.0) - r.panTimeWindow(10000.0) - assertTrue(r.snapshot().timeMaxSec >= 2000.0) - assertEquals(150.0, r.snapshot().span()) - assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) - r.sample(2001.0) - assertTrue(r.snapshot().timeMaxSec > 2001.0) - assertEquals(150.0, r.snapshot().span()) - } - - @Test fun forwardPanAfterZoomOutResumesRollingAtExpandedScale() { - val r = renderer() - r.panTimeWindow(-500.0) - r.zoomTimeWindow(.5f, .5) - assertEquals(600.0, r.snapshot().span()) - assertFalse(r.snapshot().isXAxisAuto) - r.panTimeWindow(10000.0) - assertTrue(r.snapshot().isXAxisAuto) - r.sample(1001.0) - assertTrue(r.snapshot().timeMaxSec > 1001.0) - assertEquals(600.0, r.snapshot().span()) - } - - @Test fun zoomOutReachingLatestEdgeResumesWithoutAnotherDrag() { - val r = renderer() - r.panTimeWindow(-350.0) // Move the right edge past the initial 300-second future margin. - r.zoomTimeWindow(.5f, .5) - assertEquals(1000.0, r.renderSnapshotFlow.value.timeMaxSec) - assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) - r.sample(1100.0) - assertTrue(r.snapshot().timeMaxSec > 1100.0) - assertEquals(600.0, r.snapshot().span()) - } - - @Test fun pinchAnchoredAtLatestEdgeKeepsFollowingForZoomInAndOut() { - val r = renderer() - for ((factor, expectedSpan) in listOf(2f to 150.0, .5f to 300.0, 3f to 100.0)) { - r.zoomTimeWindow(factor, 1.0) - assertTrue(r.snapshot().isXAxisAuto) - assertEquals(expectedSpan, r.snapshot().span(), 1e-8) - val latest = r.snapshot().timeMaxSec + 1.0 - r.sample(latest) - assertTrue(r.snapshot().timeMaxSec > latest) - assertEquals(expectedSpan, r.snapshot().span(), 1e-8) - } - r.resetPan() - assertEquals(300.0, r.snapshot().span()) - assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) - } - - @Test fun draggingAwayStopsFollowingAndReturningResumesWithoutChangingZoom() { - val r = renderer() - r.zoomTimeWindow(2f, 1.0) - r.panTimeWindow(-10.0) - assertFalse(r.renderSnapshotFlow.value.isXAxisAuto) - r.sample(1010.0) - assertEquals(990.0, r.snapshot().timeMaxSec) - r.panTimeWindow(19.0) - assertFalse(r.snapshot().isXAxisAuto) - assertEquals(1009.0, r.snapshot().timeMaxSec) - r.panTimeWindow(1.0) - assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) - r.sample(1011.0) - assertTrue(r.snapshot().timeMaxSec > 1011.0) - assertEquals(150.0, r.snapshot().span()) - r.resetPan() - assertEquals(300.0, r.snapshot().span()) - assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) - } - - @Test fun minimumZoomAtEpochTimestampDoesNotSnapHistoryPrematurely() { - val r = renderer() - r.sample(1700000000.0) - r.setTimeWindow(.05) - r.panTimeWindow(-.01) - r.panTimeWindow(.009) - assertFalse(r.snapshot().isXAxisAuto) - r.panTimeWindow(.001) - assertTrue(r.snapshot().isXAxisAuto) - r.sample(1700000000.1) - assertTrue(r.snapshot().timeMaxSec > 1700000000.1) - assertTrue(r.snapshot().timeMaxSec < 1700000001.0, "Epoch must not determine the extension step") - assertTrue(r.snapshot().timeMinSec <= 1700000000.1, "Extreme zoom must keep the latest sample visible") - assertEquals(.05, r.snapshot().span(), 1e-6) - } - - @Test fun forwardDragWhileFollowingMovesBeyondInitialFutureMarginAndPinsTheRange() { - val r = renderer() - r.clearAllData() - r.sample(2000.0) - val before = r.snapshot() - assertTrue(before.timeMaxSec > 2000.0) - r.panTimeWindow(10.0) - assertEquals(before.timeMaxSec + 10.0, r.snapshot().timeMaxSec) - assertEquals(before.span(), r.snapshot().span()) - assertEquals(TimeWindowPreset.MANUAL, r.snapshot().xAxisPreset) - assertFalse(r.snapshot().isXAxisAuto) - r.panTimeWindow(10.0) - assertEquals(before.timeMaxSec + 20.0, r.snapshot().timeMaxSec) - r.sample(2100.0) - assertEquals(before.timeMaxSec + 20.0, r.snapshot().timeMaxSec) - r.panTimeWindow(-5.0) - assertEquals(before.timeMaxSec + 15.0, r.snapshot().timeMaxSec) - r.resetPan() - assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) - assertTrue(r.snapshot().isXAxisAuto) - assertEquals(before.span(), r.snapshot().span()) - } - - @Test fun forwardDragCanContinueBeyondLatestAfterReturningFromHistory() { - val r = renderer() - r.panTimeWindow(-320.0) // Initial range is 1000..1300; actual history ends at 980. - r.panTimeWindow(20.0) - assertTrue(r.snapshot().isXAxisAuto) - r.panTimeWindow(10.0) - assertEquals(1010.0, r.snapshot().timeMaxSec) - assertFalse(r.snapshot().isXAxisAuto) - r.panTimeWindow(10.0) - assertEquals(1020.0, r.snapshot().timeMaxSec) - r.panTimeWindow(-30.0) - assertEquals(990.0, r.snapshot().timeMaxSec) - r.panTimeWindow(10.0) - assertEquals(1000.0, r.snapshot().timeMaxSec) - assertTrue(r.snapshot().isXAxisAuto) - } - - @Test fun zoomInFutureRangeKeepsTheAnchorAndDoesNotSnapBackToLatest() { - val r = renderer() - val initialRight = r.snapshot().timeMaxSec - r.panTimeWindow(600.0) - r.zoomTimeWindow(2f, 1.0) - assertEquals(initialRight + 600.0, r.snapshot().timeMaxSec) - assertEquals(150.0, r.snapshot().span()) - assertFalse(r.snapshot().isXAxisAuto) - assertTrue(r.snapshot().seriesList.single().points.isEmpty()) - } - - @Test fun emptyOrExplicitlyFrozenViewportDoesNotResumeWithoutReachingDataByGesture() { - val r = renderer() - r.setXAxisManualMode() - r.panTimeWindow(0.0) - r.zoomTimeWindow(1f, 1.0) - assertFalse(r.snapshot().isXAxisAuto) - r.clearAllData() - r.zoomTimeWindow(2f, 1.0) - r.panTimeWindow(100.0) - assertFalse(r.snapshot().isXAxisAuto) - assertTrue(r.snapshot().timeMaxSec.isFinite()) - } - - @Test fun resumedCustomScaleIsSavedAndOtherWindowsRemainFrozen() { - val r = renderer() - val other = renderer() - other.panTimeWindow(-50.0) - r.zoomTimeWindow(2f, 1.0) - val state = r.exportWorkspaceState() - assertTrue(state.isXAxisAuto) - assertFalse(state.isAutoTimeWindow) - assertEquals(TimeWindowPreset.MANUAL, state.timeWindowPreset) - assertEquals(150.0, state.timeWindowSec) - r.restoreWorkspaceState(r.seriesListFlow.value, state) - r.sample(2000.0) - other.sample(2000.0) - assertEquals(2000.0, r.snapshot().timeMinSec) - assertEquals(2150.0, r.snapshot().timeMaxSec) - assertEquals(150.0, r.snapshot().span()) - assertEquals(1250.0, other.snapshot().timeMaxSec) - assertFalse(other.snapshot().isXAxisAuto) - } - - @Test fun doubleTapRestoresAutoAndIncludesDataReceivedWhileBrowsingHistory() { - val r = renderer() - r.setTimeWindow(TimeWindowPreset.AUTO) - r.sample(1100.0) - r.zoomTimeWindow(2f, .5) - r.panTimeWindow(-10.0) - r.sample(2000.0) - r.resetPan() - val restored = r.snapshot() - assertEquals(TimeWindowPreset.AUTO, restored.xAxisPreset) - assertEquals(1000.0, restored.timeMinSec) - assertTrue(restored.timeMaxSec > 2000.0) - assertTrue(r.exportWorkspaceState().isAutoTimeWindow) - } - - @Test fun doubleTapPreservesCustomDurationAndSixIntervalResetScale() { - val r = renderer() - r.setTimeWindow(17.5) - r.zoomTimeWindow(2f, .5) - r.resetPan() - assertEquals(17.5, r.snapshot().span()) - r.setTimeWindow(TimeWindowPreset.MIN_1) - r.clearAllData() - r.sample(2000.0) - assertEquals(360.0, r.snapshot().span()) - r.zoomTimeWindow(3f, .5) - r.resetPan() - assertEquals(360.0, r.snapshot().span()) - assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) - } - - @Test fun selectingAnotherPresetReplacesPreviousResetTarget() { - val r = renderer() - r.zoomTimeWindow(2f, .5) - r.setTimeWindow(TimeWindowPreset.SEC_10) - r.panTimeWindow(-10.0) - r.zoomTimeWindow(2f, .5) - r.resetPan() - assertEquals(40.0, r.snapshot().span()) - assertEquals(TimeWindowPreset.SEC_10, r.snapshot().xAxisPreset) - } - - @Test fun stationaryPinchAndZeroPanDoNotEnterManualMode() { - val r = renderer() - r.zoomTimeWindow(1f, .5) - r.panTimeWindow(0.0) - r.panTimeWindow(Double.NaN) - assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) - assertTrue(r.snapshot().isXAxisAuto) - } - - @Test fun restoredManualRangeCanReturnToLatestWithoutAnOldNavigationTarget() { - val r = renderer() - r.zoomTimeWindow(3f, .5) - val signal = r.seriesListFlow.value.single() - r.restoreWorkspaceState(listOf(signal), DpcChartState(timeWindowSec = 25.0, - timeWindowPreset = TimeWindowPreset.MANUAL, isXAxisAuto = false)) - r.sample(5000.0) - r.resetPan() - assertEquals(25.0, r.snapshot().span()) - assertEquals(5000.0, r.snapshot().timeMinSec) - assertEquals(5025.0, r.snapshot().timeMaxSec) - assertTrue(r.snapshot().isXAxisAuto) - } - - @Test fun resetWithoutSamplesIsFiniteAndIndependentBetweenWindows() { - val a = renderer() - val b = renderer() - a.zoomTimeWindow(2f, .5) - b.setTimeWindow(TimeWindowPreset.SEC_10) - b.zoomTimeWindow(4f, .5) - a.resetPan() - assertEquals(300.0, a.snapshot().span()) - assertEquals(10.0, b.snapshot().span()) - a.clearAllData() - a.zoomTimeWindow(2f, .5) - a.resetPan() - assertEquals(360.0, a.snapshot().span(), .001) - assertTrue(a.snapshot().timeMaxSec.isFinite()) - } - - @Test fun directManualXAxisRangeIsPinnedBeforeAndAfterFirstSample() { - val r = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { - bindSignal("s", "Signal", AxisPosition.LEFT_0, 0x0000FFL) - } - - assertTrue(r.setXAxisManualRange(100.0, 125.0)) - assertEquals(100.0, r.snapshot().timeMinSec, 1e-8) - assertEquals(125.0, r.snapshot().timeMaxSec, 1e-8) - assertFalse(r.snapshot().isXAxisAuto) - - // A first sample must not replace the explicitly entered interval with - // the normal initial-fill range. - r.sample(1000.0) - assertEquals(100.0, r.snapshot().timeMinSec, 1e-8) - assertEquals(125.0, r.snapshot().timeMaxSec, 1e-8) - assertFalse(r.setXAxisManualRange(125.0, 125.0)) - } - - @Test fun explicitManualXAxisRangeRoundTripsThroughWorkspaceState() { - val r = renderer() - assertTrue(r.setXAxisManualRange(1010.25, 1042.75)) - val state = r.exportWorkspaceState() - assertEquals(1010.25, state.xAxisMinSec) - assertEquals(1042.75, state.xAxisMaxSec) - - val workspace = DpcWorkspaceState( - activePageId = "p", - pages = listOf( - DpcPageState( - id = "p", - title = "Page", - windows = listOf(DpcWindowState("w", "GRAPH", "Graph", chart = state)) - ) - ) - ) - val decodedState = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!! - .pages.single().windows.single().chart!! - assertEquals(1010.25, decodedState.xAxisMinSec) - assertEquals(1042.75, decodedState.xAxisMaxSec) - - val restored = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) - restored.restoreWorkspaceState(emptyList(), decodedState) - assertEquals(1010.25, restored.snapshot().timeMinSec, 1e-8) - assertEquals(1042.75, restored.snapshot().timeMaxSec, 1e-8) - assertFalse(restored.snapshot().isXAxisAuto) - } - - @Test fun clearingSamplesKeepsExplicitManualXAxisBounds() { - val r = renderer() - assertTrue(r.setXAxisManualRange(900.0, 930.0)) - r.sample(1000.0) - r.clearSamplesOnly() - assertEquals(900.0, r.snapshot().timeMinSec, 1e-8) - assertEquals(930.0, r.snapshot().timeMaxSec, 1e-8) - r.sample(1100.0) - assertEquals(900.0, r.snapshot().timeMinSec, 1e-8) - assertEquals(930.0, r.snapshot().timeMaxSec, 1e-8) - } - - @Test fun restoredExplicitManualRangeSurvivesDoubleTapReset() { - val source = renderer() - assertTrue(source.setXAxisManualRange(1010.0, 1040.0)) - val restored = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) - restored.restoreWorkspaceState(emptyList(), source.exportWorkspaceState()) - restored.sample(2000.0) - restored.resetPan() - assertEquals(1010.0, restored.snapshot().timeMinSec, 1e-8) - assertEquals(1040.0, restored.snapshot().timeMaxSec, 1e-8) - assertFalse(restored.snapshot().isXAxisAuto) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartPerformanceLifecycleTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartPerformanceLifecycleTest.kt deleted file mode 100644 index c635a0e..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChartPerformanceLifecycleTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.* -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class ChartPerformanceLifecycleTest { - @Test fun backgroundChartKeepsEverySampleAndRestoresOnlyVisibleWindows() = runTest { - val renderer = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))) - renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) - renderer.start(backgroundScope) - runCurrent() - renderer.setApplicationVisible(false) - val previous = renderer.renderSnapshotFlow.value - for (i in 1..60) renderer.addSampleSec("s", i.toDouble(), i.toDouble()) - renderer.setRenderingEnabled(true) // Late composable callback cannot override background visibility. - advanceTimeBy(100) - runCurrent() - assertSame(previous, renderer.renderSnapshotFlow.value) - assertEquals(60, renderer.seriesListFlow.value.single().buffer.size) - renderer.setRenderingEnabled(false) // Hidden page must remain hidden on return. - renderer.setApplicationVisible(true) - advanceTimeBy(100) - runCurrent() - assertSame(previous, renderer.renderSnapshotFlow.value) - renderer.setRenderingEnabled(true) - advanceTimeBy(20) - runCurrent() - assertEquals(60.0, renderer.renderSnapshotFlow.value.seriesList.single().latestValue) - renderer.dispose() - } - - @Test fun hiddenChartRetainsSamplesWithoutPublishingFramesAndDisposalReleasesBindings() = runTest { - val renderer = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))) - renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) - renderer.start(backgroundScope) - runCurrent() - renderer.setRenderingEnabled(false) - val previous = renderer.renderSnapshotFlow.value - renderer.addSampleSec("s", 100.0, 10.0) - advanceTimeBy(100) - runCurrent() - assertSame(previous, renderer.renderSnapshotFlow.value) - assertEquals(1, renderer.seriesListFlow.value.single().buffer.size) - renderer.setRenderingEnabled(true) - advanceTimeBy(20) - runCurrent() - assertEquals(10.0, renderer.renderSnapshotFlow.value.seriesList.single().latestValue) - renderer.dispose() - assertTrue(renderer.seriesListFlow.value.isEmpty()) - assertTrue(renderer.renderSnapshotFlow.value.seriesList.isEmpty()) - } - - @Test fun successivePinchesComposeBeforeBackgroundFrameIsBuilt() = runTest { - val renderer = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))) - renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) - for (i in 0..100) renderer.addSampleSec("s", i.toDouble(), i.toDouble()) - renderer.setTimeWindow(20.0) - renderer.start(backgroundScope) - runCurrent() - val before = renderer.renderSnapshotFlow.value - renderer.zoomTimeWindow(2f, 0.5) - renderer.zoomTimeWindow(2f, 0.5) - assertSame(before, renderer.renderSnapshotFlow.value, "Input must not resample synchronously") - advanceTimeBy(20) - runCurrent() - val after = renderer.renderSnapshotFlow.value - assertEquals(5.0, after.timeMaxSec - after.timeMinSec, 1e-8) - renderer.dispose() - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt deleted file mode 100644 index 7bb4d5a..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt +++ /dev/null @@ -1,167 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class ChartStoppedNavigationTest { - private fun TestScope.renderer() = ChartBatchRenderer( - TestDispatcherProvider(StandardTestDispatcher(testScheduler)) - ).apply { - bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF0000L) - for (second in 900..1000) addSampleSec("s", second.toDouble(), second.toDouble()) - setTimeWindow(30.0) - } - - // Assert the flow the UI collects, not buildSnapshot(), which would mask the missing render. - private fun TestScope.assertRight(r: ChartBatchRenderer, expected: Double, span: Double = 30.0) { - runCurrent() // A stopped chart publishes one background frame, without restarting its loop. - val frame = r.renderSnapshotFlow.value - assertEquals(expected, frame.timeMaxSec, 1e-8) - assertEquals(span, frame.timeMaxSec - frame.timeMinSec, 1e-8) - assertTrue(frame.seriesList.single().points.isNotEmpty()) - } - - @Test fun stopThenRepeatedDragsPublishBothDirectionsWithoutNewSamplesOrRenderLoop() = runTest { - val r = renderer() - val job = r.start(backgroundScope) - r.resume() - runCurrent() - r.stop() // Same renderer lifecycle used for STOP ACK and connection loss. - assertTrue(job.isCancelled) - r.panTimeWindow(-10.0) - assertRight(r, 990.0) - assertTrue(r.renderSnapshotFlow.value.isPaused) - r.panTimeWindow(-10.0) - assertRight(r, 980.0) - r.panTimeWindow(5.0) - assertRight(r, 985.0) - r.panTimeWindow(15.0) - assertRight(r, 1000.0) - assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) - r.panTimeWindow(-5.0) - assertRight(r, 995.0) - assertTrue(job.isCancelled) // Navigation must not restart measurement/render jobs. - assertEquals(900.0, r.seriesListFlow.value.single().buffer.getOldestTimestamp()) - assertEquals(1000.0, r.seriesListFlow.value.single().buffer.getLatestTimestamp()) - } - - @Test fun stoppingAnAlreadyManualViewportDoesNotLoseFurtherDragUpdates() = runTest { - val r = renderer() - r.start(backgroundScope) - r.panTimeWindow(-20.0) - runCurrent() - assertRight(r, 980.0) - r.stop() - r.panTimeWindow(5.0) - assertRight(r, 985.0) - r.panTimeWindow(-10.0) - assertRight(r, 975.0) - assertFalse(r.renderSnapshotFlow.value.isXAxisAuto) - } - - @Test fun stoppedChartPublishesFutureBlankRangeAndCanReturnWithoutNewSamples() = runTest { - val r = renderer() - val job = r.start(backgroundScope) - r.resume() - runCurrent() - r.stop() - r.panTimeWindow(60.0) - runCurrent() - val future = r.renderSnapshotFlow.value - assertEquals(1060.0, future.timeMaxSec, 1e-8) - assertEquals(1030.0, future.timeMinSec, 1e-8) - assertFalse(future.isXAxisAuto) - assertTrue(future.isPaused) - assertTrue(future.seriesList.single().points.isEmpty()) - r.panTimeWindow(-60.0) - assertRight(r, 1000.0) - r.resetPan() - assertRight(r, 1000.0) - assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) - assertTrue(job.isCancelled) - assertEquals(900.0, r.seriesListFlow.value.single().buffer.getOldestTimestamp()) - assertEquals(1000.0, r.seriesListFlow.value.single().buffer.getLatestTimestamp()) - } - - @Test fun cancelledJobAndNeverStartedRendererAlsoPublishNavigation() = runTest { - val r = renderer() - r.panTimeWindow(-10.0) - assertRight(r, 990.0) - val job = r.start(backgroundScope) - runCurrent() - job.cancel() // Non-null but inactive job must not count as a rendering consumer. - r.panTimeWindow(-5.0) - assertRight(r, 985.0) - r.panTimeWindow(10.0) - assertRight(r, 995.0) - } - - @Test fun pauseWithAnActiveLoopStillRendersDirtyNavigationAtNormalCadence() = runTest { - val r = renderer() - val job = r.start(backgroundScope) - r.resume() - runCurrent() - r.pause() - r.panTimeWindow(-10.0) - advanceTimeBy(20) - runCurrent() - assertRight(r, 990.0) - assertTrue(r.renderSnapshotFlow.value.isPaused) - r.panTimeWindow(5.0) - advanceTimeBy(20) - runCurrent() - assertRight(r, 995.0) - assertTrue(job.isActive) - } - - @Test fun stoppedZoomAndPanKeepScaleAndAllowLiveFollowAfterMeasurementResumes() = runTest { - val r = renderer() - r.start(backgroundScope) - r.resume() - runCurrent() - r.stop() - r.zoomTimeWindow(2f, 1.0) - r.panTimeWindow(-10.0) - assertRight(r, 990.0, 15.0) - r.panTimeWindow(10.0) - assertRight(r, 1000.0, 15.0) - r.resume() - r.start(backgroundScope) - r.addSampleSec("s", 1001.0, 1001.0) - runCurrent() - assertRight(r, 1002.0, 15.0) - r.stop() - r.resetPan() - assertRight(r, 1001.0, 30.0) - } - - @Test fun stoppedWindowsKeepIndependentOffsetsAndAxisSettings() = runTest { - val a = renderer() - val b = renderer() - a.setYAxisRange(-50.0, 2000.0) - a.start(backgroundScope) - b.start(backgroundScope) - runCurrent() - a.stop() - b.stop() - a.panTimeWindow(-10.0) - b.panTimeWindow(-20.0) - assertRight(a, 990.0) - assertRight(b, 980.0) - a.panTimeWindow(5.0) - assertRight(a, 995.0) - assertRight(b, 980.0) - val axis = a.renderSnapshotFlow.value.axesConfig.getValue(AxisPosition.LEFT_0) - assertFalse(axis.isAuto) - assertEquals(-50.0, axis.minVal) - assertEquals(2000.0, axis.maxVal) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartTelemetryResolverTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartTelemetryResolverTest.kt deleted file mode 100644 index fb702b6..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChartTelemetryResolverTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.chart.ChartSignalSeries -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.MdbDatabase -import com.dct.app.domain.model.mdb.SignalDefinition -import com.dct.app.domain.model.mdb.SignalGroup -import com.dct.app.domain.usecase.chart.ChartTelemetryResolver -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class ChartTelemetryResolverTest { - private fun signal(idx: Int, name: String = "Speed", reclid: Int = 0, adl: Int = 42) = - SignalDefinition(reclid, idx, name, MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 255.0, - adl = adl, messageId = 0x456) - - private fun series(id: String, name: String = "Old name") = - ChartSignalSeries(id, name, AxisPosition.LEFT_0, 0L) - - private fun database(signals: List) = - MdbDatabase(groups = listOf(SignalGroup(1, "", "", true, signals = signals))) - - @Test fun canStableLegacyHexAndCaseInsensitiveKeysKeepExistingValues() { - val resolver = ChartTelemetryResolver() - resolver.updateSources(null, listOf(signal(7)), emptyList()) - val values = mapOf("can_1110_Speed" to 17.0) - for (id in listOf("can_7", "can_7_Speed", "can_1110_speed", "CAN_0x456_SPEED", "can_Speed")) { - assertEquals(17.0, resolver.updatedValue(series(id), values, values.keys), id) - } - assertEquals(17.0, resolver.updatedValue(series("unknown", "speed"), values, values.keys)) - } - - @Test fun directKeyWinsAndUnrelatedPacketDoesNotReuseCachedValueOrCreateZero() { - val resolver = ChartTelemetryResolver() - resolver.updateSources(null, listOf(signal(7)), emptyList()) - val binding = series("can_7", "Speed") - val values = mapOf("can_7" to 0.0, "Speed" to 4.0, "can_1110_Speed" to 8.0) - assertEquals(0.0, resolver.updatedValue(binding, values, values.keys)) - assertEquals(8.0, resolver.updatedValue(binding, values, setOf("can_1110_Speed"))) - assertNull(resolver.updatedValue(binding, values, setOf("sig_1_1"))) - assertNull(resolver.updatedValue(series("absent"), values, values.keys)) - } - - @Test fun mdbAndA2lAliasesSurviveLegacyNamesAndKeepFamilyPrecedence() { - val resolver = ChartTelemetryResolver() - resolver.updateSources(database(listOf(signal(2, reclid = 1))), emptyList(), listOf(signal(3))) - val binding = series("namespace_Speed", "speed") - val values = mapOf("sig_1_2" to 12.0, "a2l_42" to 34.0, "sig_0_3" to 56.0) - assertEquals(12.0, resolver.updatedValue(binding, values, values.keys)) - assertEquals(34.0, resolver.updatedValue(binding, values, setOf("a2l_42", "sig_0_3"))) - assertEquals(56.0, resolver.updatedValue(series("SIG_0_3"), values, setOf("sig_0_3"))) - assertNull(resolver.updatedValue(binding, values, setOf("sm_01_01"))) - } - - @Test fun eachDefinitionReplacementAndRemovalInvalidatesCachedMatches() { - val resolver = ChartTelemetryResolver() - val binding = series("namespace_Speed", "speed") - val oldCan = listOf(signal(7)) - val newCan = listOf(signal(8).copy(messageId = 0x457)) - resolver.updateSources(null, oldCan, emptyList()) - assertEquals(1.0, resolver.updatedValue(binding, mapOf("can_7" to 1.0), setOf("can_7"))) - resolver.updateSources(null, newCan, emptyList()) - assertNull(resolver.updatedValue(binding, mapOf("can_7" to 1.0), setOf("can_7"))) - assertEquals(2.0, resolver.updatedValue(binding, mapOf("can_8" to 2.0), setOf("can_8"))) - resolver.updateSources(database(listOf(signal(2, reclid = 1))), emptyList(), emptyList()) - assertEquals(3.0, resolver.updatedValue(binding, mapOf("sig_1_2" to 3.0), setOf("sig_1_2"))) - resolver.updateSources(database(listOf(signal(9, reclid = 2))), emptyList(), emptyList()) - assertNull(resolver.updatedValue(binding, mapOf("sig_1_2" to 3.0), setOf("sig_1_2"))) - assertEquals(4.0, resolver.updatedValue(binding, mapOf("sig_2_9" to 4.0), setOf("sig_2_9"))) - resolver.updateSources(null, emptyList(), listOf(signal(3))) - assertEquals(5.0, resolver.updatedValue(binding, mapOf("a2l_42" to 5.0), setOf("a2l_42"))) - resolver.updateSources(null, emptyList(), listOf(signal(3, adl = 99))) - assertNull(resolver.updatedValue(binding, mapOf("a2l_42" to 5.0), setOf("a2l_42"))) - assertEquals(6.0, resolver.updatedValue(binding, mapOf("a2l_99" to 6.0), setOf("a2l_99"))) - resolver.updateSources(null, emptyList(), emptyList()) - assertNull(resolver.updatedValue(binding, mapOf("a2l_99" to 6.0), setOf("a2l_99"))) - } - - @Test fun previouslyUnresolvedBindingIsRetriedWhenDefinitionsLoadAndNameChanges() { - val resolver = ChartTelemetryResolver() - val values = mapOf("can_7" to 7.0, "can_8" to 8.0) - assertNull(resolver.updatedValue(series("namespace", "speed"), values, values.keys)) - resolver.updateSources(null, listOf(signal(7), signal(8, "RPM")), emptyList()) - assertEquals(7.0, resolver.updatedValue(series("namespace", "speed"), values, values.keys)) - assertEquals(8.0, resolver.updatedValue(series("namespace", "RPM"), values, values.keys)) - } - - @Test fun duplicateNamesUseFirstDefinitionJustLikeUncachedResolver() { - val resolver = ChartTelemetryResolver() - resolver.updateSources(null, listOf(signal(7), signal(8)), emptyList()) - val values = mapOf("can_7" to 7.0, "can_8" to 8.0) - assertEquals(7.0, resolver.updatedValue(series("namespace", "speed"), values, values.keys)) - assertNull(resolver.updatedValue(series("namespace", "speed"), values, setOf("can_8"))) - } - - /** Count definition reads instead of relying on noisy wall-clock performance thresholds. */ - @Test fun repeatedMixedPacketsDoNotScanThousandsOfDefinitionsAfterWarmup() { - val definitions = CountingList((1..2_000).map { signal(it, "CAN_$it") }) - val mdb = CountingList(listOf(signal(1, "MDB", 1))) - val a2l = CountingList(listOf(signal(1, "A2L"))) - val db = database(mdb) - val resolver = ChartTelemetryResolver() - resolver.updateSources(db, definitions, a2l) - val bindings = listOf(series("legacy", "can_2000"), series("namespace_MDB", "mdb"), series("missing")) - val values = mapOf("can_2000" to 2.0, "sig_1_1" to 3.0) - bindings.forEach { resolver.updatedValue(it, values, setOf("unrelated")) } - val reads = listOf(definitions.reads, mdb.reads, a2l.reads) - repeat(1_000) { - resolver.updateSources(db, definitions, a2l) - assertEquals(2.0, resolver.updatedValue(bindings[0], values, setOf("can_2000"))) - assertNull(resolver.updatedValue(bindings[0], values, setOf("sig_1_1"))) - assertEquals(3.0, resolver.updatedValue(bindings[1], values, setOf("sig_1_1"))) - assertNull(resolver.updatedValue(bindings[2], values, values.keys)) - } - assertEquals(reads, listOf(definitions.reads, mdb.reads, a2l.reads)) - } - - private class CountingList(private val items: List) : AbstractList() { - var reads = 0 - override val size: Int get() = items.size - override fun get(index: Int): T { - reads++ - return items[index] - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt deleted file mode 100644 index 92781a9..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt +++ /dev/null @@ -1,248 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.repository.DpcWorkspaceJson -import com.dct.app.domain.model.chart.* -import com.dct.app.domain.model.project.* -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import java.time.Instant -import java.time.ZoneId - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class ChartXAxisParityTest { - private val utc = ZoneId.of("UTC") - private fun renderer() = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { - bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000L) - } - private fun ChartBatchRenderer.sample(t: Double) = addSampleSec("s", t, t) - private fun span(s: ChartRenderSnapshot) = s.timeMaxSec - s.timeMinSec - - @Test fun fixedMenuValuesAreTickIntervalsAndUsePcMenuSpans() { - val cases = listOf(TimeWindowPreset.SEC_10 to 40.0, TimeWindowPreset.SEC_30 to 150.0, - TimeWindowPreset.MIN_1 to 300.0, TimeWindowPreset.MIN_5 to 1200.0, TimeWindowPreset.MIN_10 to 2400.0) - for ((preset, expected) in cases) { - val r = renderer() - r.sample(10000.0) - r.setTimeWindow(preset) - val s = r.buildSnapshot(600) - assertEquals(expected, span(s), 1e-8) - assertEquals(10000.0, s.timeMinSec, 1e-8) - assertEquals(10000.0 + expected, s.timeMaxSec, 1e-8) - for (width in listOf(80f, 600f, 2400f)) { - val ticks = ChartAxisTicks.time(s.timeMinSec, s.timeMaxSec, width, s.xAxisPreset, zone = utc) - assertEquals(preset.durationSec, ticks.interval.seconds, 1e-8) - assertTrue(ticks.values.size >= 4) - ticks.values.zipWithNext().forEach { (a, b) -> assertEquals(preset.durationSec, b - a, 1e-8) } - } - } - } - - @Test fun autoUsesPc60PixelRuleAndDoesNotCapAtEightIntervals() { - for ((span, step) in listOf(10.0 to 1.0, 30.0 to 5.0, 60.0 to 10.0, 300.0 to 30.0, 600.0 to 120.0)) { - assertEquals(step, ChartAxisTicks.time(0.0, span, 600f, zone = utc).interval.seconds) - } - val wide = ChartAxisTicks.time(0.0, 60.0, 1200f, zone = utc) - assertEquals(5.0, wide.interval.seconds) - assertEquals(13, wide.values.size) - } - - @Test fun transformHelperAndProductionTicksUseTheSameAlgorithm() { - assertEquals(ChartAxisTicks.time(1000.0, 1300.0, 600f).values, - MultiAxisCoordinateTransformer.generateTimeTicks(1000.0, 1300.0, 600f)) - } - - @Test fun labelsAdaptToSecondsMinutesHoursAndMilliseconds() { - val t = Instant.parse("2026-09-03T12:34:56Z").epochSecond.toDouble() - assertEquals("12:34:56", ChartAxisTicks.time(t, t + 40, 600f, TimeWindowPreset.SEC_10).label(t, utc)) - assertEquals("12:34", ChartAxisTicks.time(t, t + 300, 600f, TimeWindowPreset.MIN_1).label(t, utc)) - val hourly = ChartAxisTicks.time(t, t + 86400, 600f) - assertEquals("12:00", hourly.label(t, utc)) - val zoom = ChartAxisTicks.time(t + .1, t + .15, 600f, zone = utc) - assertTrue(zoom.values.size > 1) - assertEquals(zoom.values.size, zoom.values.map { zoom.label(it, utc) }.distinct().size) - assertTrue(zoom.values.all { it >= t + .1 && it <= t + .15 }) - } - - @Test fun longRangesUseCalendarTicksInsteadOfCappingAtTenMinutes() { - val start = Instant.parse("2026-01-01T00:00:00Z").epochSecond.toDouble() - val end = Instant.parse("2027-01-01T00:00:00Z").epochSecond.toDouble() - val ticks = ChartAxisTicks.time(start, end, 600f, zone = utc) - assertEquals(ChartAxisTicks.Unit.MONTH, ticks.interval.unit) - assertEquals(listOf("Jan 2026", "Mar 2026", "May 2026", "Jul 2026", "Sep 2026", "Nov 2026", "Jan 2027"), - ticks.values.map { ticks.label(it, utc) }) - } - - @Test fun hourlyTicksAlignToLocalClockEvenWithHalfHourTimezone() { - val zone = ZoneId.of("Asia/Kolkata") - val ticks = ChartAxisTicks.time(1700000000.0, 1700086400.0, 600f, zone = zone) - assertTrue(ticks.values.isNotEmpty()) - ticks.values.forEach { assertEquals(0, Instant.ofEpochSecond(it.toLong()).atZone(zone).minute) } - } - - @Test fun numericCompensationTicksUseRoundValuesAndFollowWidth() { - val ticks = ChartAxisTicks.numeric(0.0, 100.0, 300f) - assertEquals((0..10).map { it * 10.0 }, ticks.values) - assertEquals(listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"), ticks.values.map(ticks::label)) - assertTrue(ChartAxisTicks.numeric(0.0, 100.0, 90f).step > ticks.step) - } - - @Test fun numericTicksHandleNegativeAndTinyRangesWithoutRepeatedLabels() { - for ((min, max) in listOf(-.03 to .03, .000001 to .000002, -100.0 to -10.0)) { - val ticks = ChartAxisTicks.numeric(min, max, 300f) - assertTrue(ticks.values.isNotEmpty()) - assertEquals(ticks.values.size, ticks.values.map(ticks::label).distinct().size) - assertFalse(ticks.values.map(ticks::label).contains("-0")) - } - } - - @Test fun invalidOrCollapsedBoundsNeverGenerateUnboundedTicks() { - for ((min, max, width) in listOf(Triple(0.0, 1.0, 0f), Triple(1.0, 1.0, 600f), - Triple(Double.NaN, 2.0, 100f), Triple(0.0, Double.POSITIVE_INFINITY, 100f))) { - assertTrue(ChartAxisTicks.time(min, max, width).values.isEmpty()) - assertTrue(ChartAxisTicks.numeric(min, max, width).values.isEmpty()) - } - assertTrue(ChartAxisTicks.time(0.0, 86400.0, 1f).values.size <= 3) - } - - @Test fun labelPlacementKeepsLabelsInsidePlotWithoutOverlaps() { - val centers = listOf(50f, 80f, 110f, 140f, 170f, 200f) - val widths = List(centers.size) { 55f } - val placements = placeAxisLabels(centers, widths, 50f, 200f) - assertTrue(placements.size < centers.size) - placements.forEach { assertTrue(it.left >= 50 && it.left + widths[it.index] <= 200) } - placements.zipWithNext().forEach { (a, b) -> assertTrue(a.left + widths[a.index] + 4 <= b.left) } - assertTrue(placeAxisLabels(listOf(10f), listOf(100f), 0f, 20f).isEmpty()) - } - - @Test fun fixedRangeFillsThenReservesOneTickAtEachBoundary() { - val r = renderer() - r.setTimeWindow(TimeWindowPreset.SEC_10) - r.sample(1000.0) - val first = r.buildSnapshot(600) - assertEquals(1000.0, first.timeMinSec) - assertEquals(1040.0, first.timeMaxSec) - r.sample(1005.0) - assertEquals(first.timeMaxSec, r.buildSnapshot(600).timeMaxSec) - r.sample(1040.0) - val moved = r.buildSnapshot(600) - assertEquals(1010.0, moved.timeMinSec) - assertEquals(1050.0, moved.timeMaxSec) - r.sample(1040.033) - val rolling = r.buildSnapshot(600) - assertEquals(1010.0, rolling.timeMinSec, 1e-8) - assertEquals(1050.0, rolling.timeMaxSec, 1e-8) - r.sample(1050.0) - assertEquals(1060.0, r.buildSnapshot(600).timeMaxSec) - r.sample(5000.0) - val gap = r.buildSnapshot(600) - assertEquals(40.0, span(gap)) - assertEquals(5010.0, gap.timeMaxSec) - } - - @Test fun autoIncludesSingleSampleAndFutureMarginWithoutDegenerateRange() { - val r = renderer() - r.setTimeWindow(TimeWindowPreset.AUTO) - r.sample(1000.0) - val first = r.buildSnapshot(600) - assertEquals(1000.0, first.timeMinSec) - assertTrue(first.timeMaxSec > 1000) - r.sample(1000.01) - assertEquals(first.timeMaxSec, r.buildSnapshot(600).timeMaxSec) - r.sample(1100.0) - val next = r.buildSnapshot(600) - assertEquals(1000.0, next.timeMinSec) - assertTrue(next.timeMaxSec > 1100) - } - - @Test fun manualAndPanDoNotFollowNewSamples() { - val r = renderer() - r.sample(1000.0) - r.setTimeWindow(TimeWindowPreset.SEC_10) - r.setXAxisManualMode() - val before = r.buildSnapshot(600) - r.sample(1100.0) - assertEquals(before.timeMaxSec, r.buildSnapshot(600).timeMaxSec) - r.panTimeWindow(-5.0) - assertEquals(before.timeMinSec - 5, r.buildSnapshot(600).timeMinSec) - r.resetPan() - assertEquals(1100.0, r.buildSnapshot(600).timeMaxSec) - assertEquals(span(before), span(r.buildSnapshot(600))) - assertTrue(r.buildSnapshot(600).isXAxisAuto) - } - - @Test fun zoomPreservesAnchorAndFreezesManualRange() { - val r = renderer() - r.sample(700.0) - r.sample(1000.0) - r.setTimeWindow(TimeWindowPreset.MIN_1) - val before = r.buildSnapshot(600) - val anchor = before.timeMinSec + span(before) * .25 - r.zoomTimeWindow(2f, .25) - val zoom = r.buildSnapshot(600) - assertEquals(150.0, span(zoom)) - assertEquals(anchor, zoom.timeMinSec + span(zoom) * .25) - r.sample(2000.0) - assertEquals(zoom.timeMinSec, r.buildSnapshot(600).timeMinSec) - assertEquals(TimeWindowPreset.MANUAL, zoom.xAxisPreset) - } - - @Test fun clearUsesPcSixTickResetSpanWithoutOldTimestamps() { - for (preset in TimeWindowPreset.entries.filter { it.tickIntervalSec != null }) { - val r = renderer() - r.sample(1000.0) - r.setTimeWindow(preset) - r.clearAllData() - r.sample(2000.0) - val cleared = r.buildSnapshot(600) - assertEquals(preset.durationSec * 6, span(cleared)) - assertEquals(2000.0, cleared.timeMinSec) - assertEquals(1, cleared.seriesList.single().points.size) - } - } - - @Test fun projectRoundTripPreservesNewPresetRangeAndMigratesOldRange() { - val r = renderer() - r.setTimeWindow(TimeWindowPreset.MIN_1) - val workspace = DpcWorkspaceState("p", listOf(DpcPageState("p", "Page", windows = listOf( - DpcWindowState("w", "GRAPH", "Graph", chart = r.exportWorkspaceState()) - )))) - val decoded = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!! - val saved = decoded.pages.single().windows.single().chart!! - val restored = renderer() - restored.restoreWorkspaceState(emptyList(), saved) - assertEquals(300.0, restored.exportWorkspaceState().timeWindowSec) - restored.restoreWorkspaceState(emptyList(), saved.copy(timeWindowSec = 60.0)) - assertEquals(300.0, restored.exportWorkspaceState().timeWindowSec) - restored.restoreWorkspaceState(emptyList(), saved.copy(timeWindowSec = 60.0, - timeWindowPreset = TimeWindowPreset.MANUAL, isXAxisAuto = false)) - assertEquals(60.0, restored.exportWorkspaceState().timeWindowSec) - } - - @Test fun independentGraphControllersDoNotShareRangeOrPreset() { - val a = renderer() - val b = renderer() - a.sample(1000.0); b.sample(1000.0) - a.setTimeWindow(TimeWindowPreset.SEC_10) - b.setTimeWindow(TimeWindowPreset.MIN_5) - a.sample(1500.0) - a.buildSnapshot(600) - assertEquals(1200.0, span(b.buildSnapshot(600))) - assertEquals(2200.0, b.buildSnapshot(600).timeMaxSec) - } - - @Test fun explicitDurationApiRemainsARollingSpanNotAFixedTickPreset() { - val r = renderer() - r.setTimeWindow(30.0) - r.sample(1000.0) - val first = r.buildSnapshot(600) - assertEquals(1000.0, first.timeMinSec) - assertEquals(1030.0, first.timeMaxSec) - r.sample(1001.0) - assertEquals(1030.0, r.buildSnapshot(600).timeMaxSec) - r.sample(1031.0) - assertEquals(1036.0, r.buildSnapshot(600).timeMaxSec) - assertEquals(30.0, span(r.buildSnapshot(600))) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChecksumCalculatorTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChecksumCalculatorTest.kt deleted file mode 100644 index 003b6aa..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ChecksumCalculatorTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.dct.app.tests - -import com.google.common.truth.Truth.assertThat -import com.dct.app.data.protocol.ChecksumCalculator -import com.dct.app.data.protocol.DefaultChecksumCalculator -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource - -@DisplayName("ChecksumCalculator (8-bit NOT Checksum) 단위 테스트") -class ChecksumCalculatorTest { - - private lateinit var calculator: ChecksumCalculator - - @BeforeEach - fun setUp() { - calculator = DefaultChecksumCalculator() - } - - @Test - @DisplayName("C++ 레퍼런스 패킷(ENABLE ACK: 0xA1 0x00 0x01 0x80)의 체크섬은 0xDD이어야 함") - fun testCppReferenceEnableAckChecksum() { - // C++ DCTTestServerDlg.cpp: char buffer[] = { 0xA1, 0x00, 0x01, 0x80, 0xDD, 0x7E }; - val data = byteArrayOf(0xA1.toByte(), 0x00, 0x01, 0x80.toByte()) - val checksum = calculator.calculate(data, 0, data.size) - - // 0xA1(161) + 0x00(0) + 0x01(1) + 0x80(128) = 290 = 0x122 -> 8비트 누적합 0x22 - // 0x22 xor 0xFF = 0xDD (-35 / 221) - assertThat(checksum).isEqualTo(0xDD.toByte()) - - // 완전한 프레임 검증 - val fullFrame = byteArrayOf(0xA1.toByte(), 0x00, 0x01, 0x80.toByte(), 0xDD.toByte(), 0x7E) - assertThat(calculator.verify(fullFrame)).isTrue() - } - - @Test - @DisplayName("모든 바이트가 0x00인 경우 체크섬은 0xFF이어야 함") - fun testAllZerosChecksum() { - val zeros = ByteArray(10) { 0x00 } - val checksum = calculator.calculate(zeros) - assertThat(checksum).isEqualTo(0xFF.toByte()) - } - - @Test - @DisplayName("모든 바이트가 0xFF인 경우 체크섬 누적 오버플로우 정상 처리") - fun testAll0xFFChecksum() { - // 0xFF * 256의 누적합은 8비트에서 0이 되므로 NOT 취하면 0xFF - val data = ByteArray(256) { 0xFF.toByte() } - val checksum = calculator.calculate(data) - assertThat(checksum).isEqualTo(0xFF.toByte()) - } - - @Test - @DisplayName("대용량 4096바이트 데이터의 체크섬 누적 연산이 정확해야 함") - fun testLargeDataChecksum() { - val data = ByteArray(4096) { (it and 0xFF).toByte() } - var expectedSum = 0 - for (b in data) { - expectedSum = (expectedSum + (b.toInt() and 0xFF)) and 0xFF - } - val expectedChecksum = (expectedSum xor 0xFF).toByte() - - val actualChecksum = calculator.calculate(data) - assertThat(actualChecksum).isEqualTo(expectedChecksum) - } - - @Test - @DisplayName("체크섬 1바이트 변조 시 verify는 false를 반환해야 함") - fun testCorruptedChecksumVerification() { - val fullFrame = byteArrayOf(0xA1.toByte(), 0x00, 0x01, 0x80.toByte(), 0xDD.toByte(), 0x7E) - - // 체크섬 바이트 변조 (0xDD -> 0xDC) - fullFrame[4] = 0xDC.toByte() - assertThat(calculator.verify(fullFrame)).isFalse() - - // 페이로드 데이터 변조 - fullFrame[4] = 0xDD.toByte() - fullFrame[3] = 0x81.toByte() - assertThat(calculator.verify(fullFrame)).isFalse() - } - - @ParameterizedTest - @ValueSource(ints = [0, 1, 2, 3, 4, 5]) - @DisplayName("최소 프레임 길이(6바이트) 미만인 경우 verify는 false를 반환해야 함") - fun testUnderflowFrameLengthVerification(length: Int) { - val smallFrame = ByteArray(length) - assertThat(calculator.verify(smallFrame)).isFalse() - } - - @Test - @DisplayName("유효하지 않은 오프셋 또는 길이 입력 시 IllegalArgumentException 발생") - fun testInvalidOffsetAndLengthThrowsException() { - val data = ByteArray(10) - - assertThrows { - calculator.calculate(data, offset = -1, length = 5) - } - assertThrows { - calculator.calculate(data, offset = 0, length = -1) - } - assertThrows { - calculator.calculate(data, offset = 8, length = 5) - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ColorRefCompatibilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/ColorRefCompatibilityTest.kt deleted file mode 100644 index e8f6627..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ColorRefCompatibilityTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.theme.PcColorRef -import com.dct.app.core.theme.toColorRef -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class ColorRefCompatibilityTest { - @Test - fun legacyArgbIsConvertedToPcColorRefWithoutChannelLoss() { - assertEquals(0x00F39621L, 0xFF2196F3L.toColorRef()) - assertEquals(0x004C9A00L, 0xFF009A4CL.toColorRef()) - } - - @Test - fun existingPcColorRefRemainsUnchanged() { - val colorRef = PcColorRef.fromRgb(0x12, 0x34, 0x56) - assertEquals(0x00563412L, colorRef) - assertEquals(colorRef, colorRef.toColorRef()) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/CompensationLayoutTest.kt b/Android/app/src/test/java/com/dct/app/tests/CompensationLayoutTest.kt deleted file mode 100644 index 5988a27..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/CompensationLayoutTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.MultiAxisCoordinateTransformer -import com.dct.app.domain.model.mdb.* -import com.dct.app.presentation.signaltree.* -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class CompensationLayoutTest { - @Test fun physicalValuePreservesUpToFourDecimalPlacesForBothTypes() { - val expected = mapOf(12.0 to "12", 12.3 to "12.3", 12.34 to "12.34", - 12.345 to "12.345", 12.34567 to "12.3457", -0.00001 to "0", -12.3456 to "-12.3456") - for ((value, text) in expected) { - assertEquals(text, formatCompensationPhysicalValue(value)) - for (typeA in listOf(true, false)) { - assertEquals(text, compensationPointColumns(typeA).single { it.title == "PHY" } - .text(MapPoint(0, 0, 0, value))) - } - } - assertEquals("N/A", formatCompensationPhysicalValue(Double.NaN)) - } - - @Test fun coefficientPrecisionMatchesPcTypeAAndTypeB() { - for (typeA in listOf(true, false)) { - val columns = compensationMapColumns(typeA) - val sample = map().copy(cvsA = 0.12345, cvsB = -1.23456) - assertEquals(if (typeA) "0.1235" else "0.123", columns.single { it.title == "CVS_A" }.text(sample)) - assertEquals(if (typeA) "-1.2346" else "-1.235", columns.single { it.title == "CVS_B" }.text(sample)) - assertEquals(if (typeA) "1.0000" else "1.000", formatCompensationCoefficient(1.0, typeA)) - } - } - - private fun map(type: CompensationMapType = CompensationMapType.TYPE_A) = CompensationMap( - type, 1, "Y", 0, MdbAttrType.ATTR_3_INT16, 0.0, 100.0, - "X", 0, MdbAttrType.ATTR_3_INT16, 1.0, 0.0, - points = listOf(MapPoint(0, 10, -20, -30.0), MapPoint(1, 20, 200, 150.0)) - ) - - @Test fun typeARangeIncludesDataOutsideConfiguredLimitsWithoutChangingSamples() { - val original = map() - val curve = compensationCurveGeometry(original)!! - assertEquals(-20.0, curve.minY) - assertEquals(200.0, curve.maxY) - assertEquals(listOf(-30.0 to -20.0, 150.0 to 200.0), curve.points) - assertEquals(100.0, original.yMax) - assertEquals(200, original.points.last().data) - } - - @Test fun typeBUsesBkpAndPhyAndIncludesConfiguredRangeAndZero() { - val original = map(CompensationMapType.TYPE_B).copy(yMin = -100.0, yMax = 300.0) - val curve = compensationCurveGeometry(original)!! - assertEquals(listOf(10.0 to -30.0, 20.0 to 150.0), curve.points) - assertEquals(-100.0, curve.minY) - assertEquals(300.0, curve.maxY) - val positive = compensationCurveGeometry(original.copy(yMin = 10.0, yMax = 20.0, - points = listOf(MapPoint(0, 1, 0, 15.0))))!! - assertEquals(0.0, positive.minY) - } - - @Test fun equalValuesHaveNonzeroDisplayRanges() { - val curve = compensationCurveGeometry(map().copy(yMin = 0.0, yMax = 0.0, - points = listOf(MapPoint(0, 0, 0, 0.0))))!! - assertEquals(-10.0, curve.minX) - assertEquals(10.0, curve.maxX) - assertEquals(-10.0, curve.minY) - assertEquals(10.0, curve.maxY) - } - - @Test fun invalidDataDoesNotPoisonRangeAndRemainsAGap() { - val original = map().copy(yMin = Double.NaN, yMax = Double.POSITIVE_INFINITY, - points = listOf(MapPoint(0, 1, 10, 1.0), MapPoint(1, 2, 20, Double.NaN), MapPoint(2, 3, 30, 3.0))) - val curve = compensationCurveGeometry(original)!! - assertEquals(1.0, curve.minX) - assertEquals(3.0, curve.maxX) - assertTrue(curve.points[1].first.isNaN()) - assertNull(compensationCurveGeometry(original.copy(points = listOf(original.points[1])))) - assertNull(compensationCurveGeometry(original.copy(points = emptyList()))) - } - - @Test fun compensationBoundsStayInsideCanvasAndRejectTooSmallAreas() { - assertNull(compensationPlotBounds(81f, 300f)) - assertNull(compensationPlotBounds(300f, 63f)) - assertNull(compensationPlotBounds(Float.NaN, 300f)) - val bounds = compensationPlotBounds(300f, 200f)!! - assertEquals(60f, bounds.left) - assertEquals(288f, bounds.right) - assertEquals(162f, bounds.bottom) - assertTrue(bounds.isDrawable) - } - - @Test fun timeGraphBoundsNeverExceedCanvasEvenWithManyAxesAndLargeLegend() { - for (width in listOf(0f, 20f, 120f, 300f, 1000f)) { - for (height in listOf(0f, 10f, 50f, 300f)) { - for (legend in 0..3) { - val b = MultiAxisCoordinateTransformer.calculatePlotBounds(width, height, 4, - rightAxisCount = 4, legendPosition = legend, legendWidthPx = 600f, seriesCount = 20) - assertTrue(b.left in 0f..width && b.right in b.left..width) - assertTrue(b.top in 0f..height && b.bottom in b.top..height) - } - } - } - assertFalse(MultiAxisCoordinateTransformer.calculatePlotBounds(0f, 0f, 1).isDrawable) - } - - @Test fun columnDefinitionsPreserveTypeSpecificOrderAndValues() { - assertEquals(listOf("IDX", "PHY", "BKP", "Data"), compensationPointColumns(true).map { it.title }) - assertEquals(listOf("IDX", "BKP", "Data", "PHY"), compensationPointColumns(false).map { it.title }) - assertEquals(listOf("0", "-30", "10", "-20"), compensationPointColumns(true).map { it.text(map().points[0]) }) - for (typeA in listOf(true, false)) { - val columns = compensationMapColumns(typeA) - assertEquals(11, columns.size) - assertEquals("DESCRIPTION", columns.last().title) - assertEquals(11, columns.map { it.title }.distinct().size) - assertEquals(if (typeA) "XNAME" else "CVS_A", columns[5].title) - } - } - - @Test fun pointAndMetadataWidthsAreIsolatedAndRestored() { - var saved = emptyMap() - val widths = WindowColumnWidths(WindowUiStore(emptyMap()) { saved = it }) - widths["comp:map:YNAME"] = androidx.compose.ui.unit.Dp(240f) - widths["comp:point:PHY"] = androidx.compose.ui.unit.Dp(110f) - val restored = WindowColumnWidths(WindowUiStore(saved) {}) - assertEquals(240f, restored["comp:map:YNAME"]!!.value) - assertEquals(110f, restored["comp:point:PHY"]!!.value) - assertNull(restored["comp:map:PHY"]) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/ConstantPresentationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ConstantPresentationTest.kt deleted file mode 100644 index 8f0728f..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/ConstantPresentationTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.mdb.* -import com.dct.app.presentation.signaltree.* -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class ConstantPresentationTest { - private val constant = MdbConstant(1, "Torque", 350.0, 0x1000, 0.0, 500.0, MdbAttrType.ATTR_2_UINT16) - - @Test fun numbersAreIntegersIncludingUint32WithoutExponentOrNegativeZero() { - assertEquals("350", formatConstantInteger(350.0)) - assertEquals("4294967295", formatConstantInteger(4294967295.0)) - assertEquals("-2147483648", formatConstantInteger(-2147483648.0)) - assertEquals("0", formatConstantInteger(-0.0)) - for (bad in listOf(Double.NaN, Double.POSITIVE_INFINITY, 1.5)) assertEquals("N/A", formatConstantInteger(bad)) - } - - @Test fun editorRejectsFractionsNonFiniteAndMdbRangeViolations() { - for (text in listOf("", "abc", "NaN", "Infinity", "1.5", "501", "-1")) assertNotNull(constantInputError(text, constant), text) - for (text in listOf("0", "350", "500", "350.0")) assertNull(constantInputError(text, constant), text) - } - - @Test fun allSignedAndUnsignedAttributeBoundariesAreChecked() { - for (attr in MdbAttrType.entries) { - val bits = attr.byteSize * 8 - val min = if (attr.isSigned) -(1L shl (bits - 1)) else 0L - val max = if (attr.isSigned) (1L shl (bits - 1)) - 1 else (1L shl bits) - 1 - val c = constant.copy(attr = attr, min = -1e15, max = 1e15) - assertNull(constantInputError(min.toString(), c)) - assertNull(constantInputError(max.toString(), c)) - assertNotNull(constantInputError((min - 1).toString(), c)) - assertNotNull(constantInputError((max + 1).toString(), c)) - } - } - - @Test fun checkedFilterUsesStableIdsAndDoesNotLoseChecksWhenSearchChanges() { - val constants = listOf(constant, constant.copy(idx = 2, name = "RPM")) - assertEquals(listOf(constant), filterConstants(constants, "torQUE", emptySet(), false)) - assertEquals(listOf(constants[1]), filterConstants(constants, "torQUE", setOf(2L), true)) - assertTrue(filterConstants(constants, "", emptySet(), true).isEmpty()) - assertEquals(constants, filterConstants(constants, "", setOf(2L), false)) - } - - @Test fun invalidMetadataRangeDoesNotPermitWrites() { - for (c in listOf(constant.copy(min = Double.NaN), constant.copy(max = Double.POSITIVE_INFINITY), constant.copy(min = 600.0))) { - assertNotNull(constantInputError("350", c)) - } - } -} 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 deleted file mode 100644 index 494bb29..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt +++ /dev/null @@ -1,218 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.data.protocol.DctProtocolRequests -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.DctCanMessageConfiguration -import com.dct.app.domain.model.project.createDefaultSensorModules -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import java.time.LocalDateTime - -@DisplayName("PC DCT 프로토콜 요청 바이트 정합성") -class DctProtocolRequestsTest { - @Test - fun `P3 maintenance request bytes match PC protocol`() { - assertThat(DctProtocolRequests.savedLogInfo(0x1234).payload) - .isEqualTo(byteArrayOf(0x02, 0x12, 0x34, 0x01)) - assertThat(DctProtocolRequests.savedLogChunk(0x1234, 0x01020304, 4096).payload) - .isEqualTo(byteArrayOf(0x02, 0x12, 0x34, 0x01, 0x02, 0x03, 0x04, 0x10, 0x00)) - assertThat(DctProtocolRequests.firmwareChunk(2, 0x0123, true, byteArrayOf(0x55, 0x66)).payload) - .isEqualTo(byteArrayOf(0x05, 0x02, 0x01, 0x23, 0x02, 0x00, 0x02, 0x55, 0x66)) - assertThat(DctProtocolRequests.firmwareKeySuccess(byteArrayOf(0x11, 0x22), 0x01020304, 99).payload) - .isEqualTo(byteArrayOf(0x02, 0x01, 0x02, 0x11, 0x22, 0x01, 0x02, 0x03, 0x04, 99)) - assertThat(DctProtocolRequests.firmwareKeyFailure().payload) - .isEqualTo(byteArrayOf(0x02, 0x02, 0x00)) - - // [ISSUE-R1-01] OTA firmware subcommands (0x06, 0x07, 0x08) & Heartbeat (0x05) - val removePacket = DctProtocolRequests.firmwareRemove(2) - assertThat(removePacket.command).isEqualTo(0x31.toByte()) - assertThat(removePacket.payload).isEqualTo(byteArrayOf(0x06, 0x02)) - - val removeBytes = DctProtocolRequests.buildRemoveFirmwareRequest(2) - assertThat(removeBytes.first()).isEqualTo(0xA1.toByte()) - assertThat(removeBytes.last()).isEqualTo(0x7E.toByte()) - - val triggerPacket = DctProtocolRequests.firmwareTriggerUpdate(0) - assertThat(triggerPacket.command).isEqualTo(0x31.toByte()) - assertThat(triggerPacket.payload).isEqualTo(byteArrayOf(0x07, 0x00)) - - val triggerBytes = DctProtocolRequests.buildTriggerFirmwareUpdateRequest() - assertThat(triggerBytes.first()).isEqualTo(0xA1.toByte()) - assertThat(triggerBytes.last()).isEqualTo(0x7E.toByte()) - - val progressAck = DctProtocolRequests.firmwareUpdateResponse(0x01) - assertThat(progressAck.command).isEqualTo(0x31.toByte()) - assertThat(progressAck.payload).isEqualTo(byteArrayOf(0x08, 0x01)) - - val completedAck = DctProtocolRequests.firmwareUpdateResponse(0x02) - assertThat(completedAck.command).isEqualTo(0x31.toByte()) - assertThat(completedAck.payload).isEqualTo(byteArrayOf(0x08, 0x02)) - - val alivePacket = DctProtocolRequests.possibleToConnect() - assertThat(alivePacket.command).isEqualTo(0x05.toByte()) - assertThat(alivePacket.payload).isEmpty() - } - private val codec = DctPacketCodec() - - @Test - fun `핵심 제어 명령은 PC 골든 프레임과 일치한다`() { - assertThat(codec.encode(DctProtocolRequests.enable())).isEqualTo(hex("A1 00 01 00 5D 7E")) - assertThat(codec.encode(DctProtocolRequests.version())).isEqualTo(hex("A1 00 01 01 5C 7E")) - assertThat(codec.encode(DctProtocolRequests.hardwareId())).isEqualTo(hex("A1 00 01 03 5A 7E")) - assertThat(codec.encode(DctProtocolRequests.startAll())).isEqualTo(hex("A1 00 02 20 01 3B 7E")) - assertThat(codec.encode(DctProtocolRequests.stop())).isEqualTo(hex("A1 00 01 21 3C 7E")) - assertThat(codec.encode(DctProtocolRequests.canStreamAck())).isEqualTo(hex("A1 00 02 23 01 38 7E")) - assertThat(codec.encode(DctProtocolRequests.smStreamAck())).isEqualTo(hex("A1 00 02 24 01 37 7E")) - assertThat(codec.encode(DctProtocolRequests.xcpStreamAck())).isEqualTo(hex("A1 00 02 50 01 0B 7E")) - assertThat(codec.encode(DctProtocolRequests.dctEnvQuery(0x00))).isEqualTo(hex("A1 00 02 09 00 53 7E")) - assertThat(codec.encode(DctProtocolRequests.dctEnvQuery(0x03))).isEqualTo(hex("A1 00 02 09 03 50 7E")) - assertThat(codec.encode(DctProtocolRequests.smInfoCountQuery())).isEqualTo(hex("A1 00 03 12 01 00 48 7E")) - } - - @Test - fun `프로젝트 DB 설정은 PC SendDBInfo 형식과 일치한다`() { - val request = DctProtocolRequests.dbInfo("HE1ID") - // HE1ID: [0x00][length 5][48 45 31 49 44] - assertThat(request.payload).isEqualTo(byteArrayOf(0x00, 5, 0x48, 0x45, 0x31, 0x49, 0x44)) - } - - @Test - fun `시간 동기화는 PC의 8바이트 날짜 형식을 사용한다`() { - val monday = LocalDateTime.of(2026, 8, 31, 14, 5, 9) - assertThat(DctProtocolRequests.syncTime(monday).payload).isEqualTo( - hex("0E 05 09 07 EA 08 1F 40") - ) - } - - @Test - fun `MDB 상수 주소는 범위에 따라 3바이트 또는 4바이트로 직렬화한다`() { - assertThat(DctProtocolRequests.constantWrite(0x123456, hex("12 34")).payload) - .isEqualTo(hex("03 12 34 56 02 12 34")) - assertThat(DctProtocolRequests.constantRead(0x12345678, 4).payload) - .isEqualTo(hex("04 12 34 56 78 04")) - } - - @Test - fun `XCP Characteristic은 타입 값 주소 순서를 따른다`() { - val request = DctProtocolRequests.xcpCharacteristicWrite( - address = 0x12345678, - dataType = A2lDataType.UWORD, - value = 0x1020.toDouble(), - isMsbFirst = true - ) - assertThat(request.payload).isEqualTo(hex("02 10 20 12 34 56 78")) - - val read = DctProtocolRequests.xcpCharacteristicRead( - address = 0x12345678, - dataType = A2lDataType.UWORD, - isMsbFirst = false - ) - assertThat(read.payload).isEqualTo(hex("02 78 56 34 12")) - } - - @Test - fun `XCP 측정 목록은 FLOAT 원본 타입과 주소 엔디안을 보존한다`() { - val signal = SignalDefinition( - reclid = 0, - idx = 0, - name = "FloatValue", - attr = MdbAttrType.ATTR_4_UINT32, - cvsA = 1.0, - cvsB = 0.0, - unit = "", - color = 0, - min = 0.0, - max = 1.0, - adl = 0x12345678, - a2lDataType = A2lDataType.FLOAT32_IEEE - ) - - assertThat(DctProtocolRequests.xcpMeasurements(listOf(signal), isMsbFirst = true).payload) - .isEqualTo(hex("00 01 09 12 34 56 78")) - assertThat(DctProtocolRequests.xcpMeasurements(listOf(signal), isMsbFirst = false).payload) - .isEqualTo(hex("00 01 09 78 56 34 12")) - } - - @Test - fun `DID 명령과 응답 값은 PC enum과 일치한다`() { - assertThat(com.dct.app.domain.model.protocol.DctCommand.CMD_DID_INFO.code).isEqualTo(0x15.toByte()) - assertThat(com.dct.app.domain.model.protocol.DctCommand.CMD_REQ_ALL_DID_INFO.code).isEqualTo(0x18.toByte()) - assertThat(com.dct.app.domain.model.protocol.DctCommand.RES_DID_INFO.code).isEqualTo(0x95.toByte()) - assertThat(com.dct.app.domain.model.protocol.DctCommand.RES_REQ_ALL_DID_INFO.code).isEqualTo(0x98.toByte()) - } - - @Test - fun `P0 장비 설정 페이로드는 PC MainFrm 직렬화와 일치한다`() { - val signals = listOf( - SignalDefinition(1, 1, "Speed", MdbAttrType.ATTR_2_UINT16, 1.0, 0.0, "", 0, 0.0, 1.0), - SignalDefinition(1, 2, "Temp", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 1.0) - ) - - assertThat(DctProtocolRequests.carType("NQ5", 2).payload) - .isEqualTo(hex("03 4E 51 35 02")) - assertThat(DctProtocolRequests.mdbInfo(signals).payload) - .isEqualTo(hex("01 00 01 00 02 00 01 02 00 02 01")) - assertThat(DctProtocolRequests.mdbSelectedIds(signals).payload) - .isEqualTo(hex("01 01 02 01 02")) - assertThat(DctProtocolRequests.canDbcInfo(listOf(DctCanMessageConfiguration(0x7E0, 8))).payload) - .isEqualTo(hex("01 00 01 07 E0 08")) - assertThat(DctProtocolRequests.canDbcInfo(emptyList()).payload) - .isEqualTo(hex("00 00 00")) - assertThat(DctProtocolRequests.sensorModuleInfo(createDefaultSensorModules()).payload) - .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) - assertThat(runCatching { - DctProtocolRequests.mdbSelectedIds(listOf(base, base.copy(reclid = 257, name = "B"))) - }.isFailure).isTrue() - assertThat(runCatching { - DctProtocolRequests.mdbSelectedIds(listOf(base, base.copy(idx = 257, name = "B"))) - }.isFailure).isTrue() - } - - @Test - fun `PC처럼 넓은 원본 ID를 전송 필드 하위 바이트로 직렬화한다`() { - val wideSignal = SignalDefinition( - 0x1234, 0x5678, "Wide", MdbAttrType.ATTR_0_UINT8, - 1.0, 0.0, "", 0, 0.0, 1.0 - ) - - assertThat(DctProtocolRequests.mdbInfo(listOf(wideSignal)).payload) - .isEqualTo(hex("01 12 34 00 01 56 78 01")) - assertThat(DctProtocolRequests.mdbSelectedIds(listOf(wideSignal)).payload) - .isEqualTo(hex("01 34 01 78")) - assertThat(DctProtocolRequests.canDbcInfo(listOf(DctCanMessageConfiguration(0x800007E0, 8))).payload) - .isEqualTo(hex("01 00 01 07 E0 08")) - } - - private fun hex(value: String): ByteArray = value.trim().split(Regex("\\s+")) - .map { it.toInt(16).toByte() } - .toByteArray() -} diff --git a/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt deleted file mode 100644 index 9ae1eb9..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.protocol.CanSignalBitDecoder -import com.dct.app.data.protocol.CanFrameSignalDecoder -import com.dct.app.data.protocol.DctStreamPayloadParser -import com.dct.app.data.protocol.MdbRecordDecoder -import com.dct.app.domain.model.mdb.CanMultiplexRole -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.SignalDefinition -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -@DisplayName("DCT A3/A4 실시간 payload 파서") -class DctStreamPayloadParserTest { - @Test - fun `MDB 스트림은 type과 ReclID를 데이터에서 분리한다`() { - val parsed = DctStreamPayloadParser.parseCanStream(hex("01 07 34 12")) - as DctStreamPayloadParser.CanStreamPayload.Mdb - assertThat(parsed.recordId).isEqualTo(7) - assertThat(parsed.data).isEqualTo(hex("34 12")) - } - - @Test - fun `CAN 스트림은 페이지와 복수 프레임 및 NA flag를 파싱한다`() { - val parsed = DctStreamPayloadParser.parseCanStream( - hex("02 01 02 02 01 23 02 AA BB 04 56 81 CC") - ) as DctStreamPayloadParser.CanStreamPayload.Can - assertThat(parsed.currentPage).isEqualTo(1) - assertThat(parsed.totalPages).isEqualTo(2) - assertThat(parsed.frames).hasSize(2) - assertThat(parsed.frames[0].canId).isEqualTo(0x0123) - assertThat(parsed.frames[0].data).isEqualTo(hex("AA BB")) - assertThat(parsed.frames[0].isReceived).isTrue() - assertThat(parsed.frames[1].canId).isEqualTo(0x0456) - assertThat(parsed.frames[1].isReceived).isFalse() - } - - @Test - fun `SM 스트림은 모듈 채널 부호 소수와 NC를 파싱한다`() { - val parsed = DctStreamPayloadParser.parseSensorStream( - hex("01 02 03 03 10 25 00 22 04 FF FF 00 00 05 01 23 00 38") - )!! - assertThat(parsed).hasSize(1) - assertThat(parsed[0].moduleIndex).isEqualTo(2) - assertThat(parsed[0].channels[0].channelIndex).isEqualTo(3) - assertThat(parsed[0].channels[0].value).isWithin(0.0001).of(-37.34) - assertThat(parsed[0].channels[1].isNotConnected).isTrue() - assertThat(parsed[0].channels[1].value).isNull() - assertThat(parsed[0].channels[2].value).isWithin(0.0001).of(291.56) - } - - @Test - fun `잘린 프레임과 잘못된 DLC는 거절한다`() { - assertThat(DctStreamPayloadParser.parseCanStream(hex("02 01 01 01 23"))).isNull() - assertThat(DctStreamPayloadParser.parseCanStream(hex("02 01 01 01 23 01 41"))).isNull() - assertThat(DctStreamPayloadParser.parseSensorStream(hex("01 00 01 00 01"))).isNull() - } - - @Test - fun `Intel 및 Motorola DBC bit field를 추출한다`() { - assertThat(CanSignalBitDecoder.decode(hex("F0 0A"), 4, 8, false, false)).isEqualTo(0xAF) - assertThat(CanSignalBitDecoder.decode(hex("12 34"), 7, 8, true, false)).isEqualTo(0x12) - assertThat(CanSignalBitDecoder.decode(hex("F0"), 4, 4, false, true)).isEqualTo(-1) - } - - @Test - fun `멀티플렉서 값과 일치하는 DBC 신호만 디코딩한다`() { - fun signal(name: String, startBit: Int, role: CanMultiplexRole, mux: Long? = null) = - SignalDefinition( - reclid = 0, - idx = startBit, - name = name, - attr = MdbAttrType.ATTR_0_UINT8, - cvsA = 1.0, - cvsB = 0.0, - unit = "", - color = 0, - min = 0.0, - max = 255.0, - startBit = startBit, - bitLength = if (role == CanMultiplexRole.MULTIPLEXOR) 4 else 8, - multiplexRole = role, - multiplexValue = mux - ) - - val decoded = CanFrameSignalDecoder.decode( - data = hex("07 2A 63"), - signals = listOf( - signal("Mode", 0, CanMultiplexRole.MULTIPLEXOR), - signal("Value0", 8, CanMultiplexRole.MULTIPLEXED, 0), - signal("Value7", 8, CanMultiplexRole.MULTIPLEXED, 7), - signal("Always", 16, CanMultiplexRole.NONE) - ) - ) - - assertThat(decoded.map { it.definition.name }).containsExactly("Mode", "Value7", "Always").inOrder() - assertThat(decoded.first { it.definition.name == "Value7" }.physicalValue).isEqualTo(42.0) - } - - @Test - fun `스트림의 중복 인덱스 잔여 바이트와 잘린 XCP 항목을 거절한다`() { - assertThat(DctStreamPayloadParser.parseCanStream(hex("02 01 01 00 FF"))).isNull() - assertThat(DctStreamPayloadParser.parseSensorStream(hex("01 00 00 FF"))).isNull() - assertThat(DctStreamPayloadParser.parseSensorStream(hex("02 00 00 00 00"))).isNull() - assertThat(DctStreamPayloadParser.parseXcpStream(hex("00 01 02 34"))).isNull() - - val xcp = DctStreamPayloadParser.parseXcpStream(hex("00 02 02 A0 0F 00 96"))!! - assertThat(xcp.values).hasSize(2) - assertThat(xcp.values[0].data).isEqualTo(hex("A0 0F")) - } - - @Test - fun `MDB 정의 길이가 달라도 수신 가능한 신호는 부분 디코딩한다`() { - val first = SignalDefinition( - 7, 1, "First", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 255.0, - arrayIndex = 0 - ) - val second = SignalDefinition( - 7, 2, "Second", MdbAttrType.ATTR_2_UINT16, 1.0, 0.0, "", 0, 0.0, 65535.0, - arrayIndex = 1 - ) - val success = MdbRecordDecoder.decode(7, hex("0A 12 34"), listOf(first, second)) - as MdbRecordDecoder.DecodeResult.Success - assertThat(success.signals.map { it.physicalValue }).containsExactly(10.0, 4660.0).inOrder() - - val partial = MdbRecordDecoder.decode(7, hex("0A 12"), listOf(first, second)) - as MdbRecordDecoder.DecodeResult.Success - assertThat(partial.signals.map { it.definition.name }).containsExactly("First") - assertThat(partial.signals.single().physicalValue).isEqualTo(10.0) - assertThat(partial.warning).contains("필요=3, 수신=2") - assertThat(MdbRecordDecoder.decode(7, hex("0A 12 34"), listOf(first, second.copy(idx = 1)))) - .isInstanceOf(MdbRecordDecoder.DecodeResult.Failure::class.java) - - val extra = MdbRecordDecoder.decode(7, hex("0A 12 34 FF"), listOf(first, second)) - as MdbRecordDecoder.DecodeResult.Success - assertThat(extra.signals).hasSize(2) - assertThat(extra.warning).contains("초과 바이트 1B 무시") - } - - @Test - fun `64비트 unsigned CAN raw는 음수로 변환하지 않는다`() { - val signal = SignalDefinition( - 0, 0, "U64", MdbAttrType.ATTR_4_UINT32, 1.0, 0.0, "", 0, 0.0, Double.MAX_VALUE, - startBit = 0, - bitLength = 64 - ) - val decoded = CanFrameSignalDecoder.decode(ByteArray(8) { 0xFF.toByte() }, listOf(signal)).single() - assertThat(decoded.physicalValue).isGreaterThan(Long.MAX_VALUE.toDouble()) - } - - @Test - fun `DBC signed 값은 PC 저장 버킷 캐스팅 규칙을 따른다`() { - fun signed(length: Int) = SignalDefinition( - 0, length, "S$length", MdbAttrType.ATTR_1_INT8, 1.0, 0.0, "", 0, -1.0, 1.0, - startBit = 0, - bitLength = length - ) - - // PC는 4-bit 0xF와 12-bit 0xFFF를 각각 int8/int16 버킷으로 캐스팅한다. - assertThat(CanFrameSignalDecoder.decode(hex("0F"), listOf(signed(4))).single().rawValue).isEqualTo(15) - assertThat(CanFrameSignalDecoder.decode(hex("FF 0F"), listOf(signed(12))).single().rawValue).isEqualTo(4095) - assertThat(CanFrameSignalDecoder.decode(hex("FF"), listOf(signed(8))).single().rawValue).isEqualTo(-1) - assertThat(CanFrameSignalDecoder.decode(hex("FF FF"), listOf(signed(16))).single().rawValue).isEqualTo(-1) - } - - private fun hex(value: String): ByteArray = value.trim().split(Regex("\\s+")) - .map { it.toInt(16).toByte() } - .toByteArray() -} diff --git a/Android/app/src/test/java/com/dct/app/tests/DctVersionResponseTest.kt b/Android/app/src/test/java/com/dct/app/tests/DctVersionResponseTest.kt deleted file mode 100644 index de142ad..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/DctVersionResponseTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.data.protocol.DctProtocolRequests -import com.dct.app.data.protocol.DctResponseValidator -import com.dct.app.domain.model.protocol.DctPacket -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class DctVersionResponseTest { - private fun validate(payload: ByteArray) { - // Include wire encoding/decoding; the frame length and version length are different fields. - val codec = DctPacketCodec() - val response = codec.decode(codec.encode(DctPacket(command = 0x81.toByte(), payload = payload)))!! - DctResponseValidator.validateSetup(DctProtocolRequests.version(), response) - } - - @Test fun acceptsPcLengthPrefixedVersion() { - val version = "DCT-MOBI-v1.0.0".toByteArray(Charsets.US_ASCII) - assertEquals(15, version.size) - validate(byteArrayOf(0x0F) + version) - } - - @Test fun versionLengthIsUnsigned() { - validate(byteArrayOf(0xFF.toByte()) + ByteArray(255) { 65 }) - } - - @Test fun acceptsOnlyIdentifiableNullTerminatedLegacyTestVersions() { - validate("DCT-MOBI-v1.0.0\u0000".toByteArray(Charsets.US_ASCII)) - validate("DCT-MOBI-v12.34.56\u0000".toByteArray(Charsets.US_ASCII)) - } - - @Test fun rejectsMalformedLengthPrefixedResponses() { - listOf( - byteArrayOf(), byteArrayOf(0), byteArrayOf(1), byteArrayOf(8, 65), - byteArrayOf(1, 65, 66), byteArrayOf(0xFF.toByte()) + ByteArray(254) { 65 }, - byteArrayOf(15) + "DCT-MOBI-v1.0.0\u0000".toByteArray(Charsets.US_ASCII) - ).forEach { payload -> - assertThrows(IllegalArgumentException::class.java) { validate(payload) } - } - } - - @Test fun legacyCompatibilityDoesNotAcceptArbitraryTextOrCorruptedData() { - listOf( - "DCT-MOBI-v1.0.0", "DCT-MOBI-v1.0.0\u0000\u0000", - "DCT-MOBI-v1.0.0\u0000junk", "DCT-MOBI-v1.0\u0000", - "DCT-MOBI-v1.A.0\u0000", "DCT-MOBI-v1.\u0000.0\u0000", "other firmware\u0000", - "DCT-MOBI-v" + "1".repeat(250) + ".0.0\u0000" - ).forEach { text -> - assertThrows(IllegalArgumentException::class.java) { validate(text.toByteArray(Charsets.US_ASCII)) } - } - } - - @Test fun validVersionCannotApproveWrongResponseCommand() { - assertThrows(IllegalArgumentException::class.java) { - DctResponseValidator.validateSetup( - DctProtocolRequests.version(), DctPacket(command = 0x83.toByte(), payload = byteArrayOf(1, 65)) - ) - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt b/Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt deleted file mode 100644 index 48ce316..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.dct.app.tests - -import android.content.SharedPreferences -import com.dct.app.data.preferences.DeveloperOptionsPreferences -import com.dct.app.presentation.main.DeveloperOptionsAccess -import io.mockk.every -import io.mockk.mockk -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class DeveloperOptionsTest { - @Test fun fiveConsecutiveTapsUnlockThenOneTapReopensUntilANewSession() { - val access = DeveloperOptionsAccess() - repeat(4) { assertFalse(access.onTitleTap(it * 150L)) } - assertTrue(access.onTitleTap(600)) - assertTrue(access.onTitleTap(100_000)) - assertFalse(DeveloperOptionsAccess().onTitleTap(100_150)) - } - - @Test fun pausesAndClockDiscontinuityResetTheTapSequence() { - val access = DeveloperOptionsAccess() - repeat(4) { assertFalse(access.onTitleTap(it * 100L)) } - assertFalse(access.onTitleTap(1_301)) - assertFalse(access.onTitleTap(1_000)) - repeat(3) { assertFalse(access.onTitleTap(1_100L + it * 100)) } - assertTrue(access.onTitleTap(1_400)) - } - - @Test fun closingTheUiRelocksEvenIfTheBackgroundSessionIsRetained() { - val access = DeveloperOptionsAccess() - repeat(5) { access.onTitleTap(it * 100L) } - assertTrue(access.onTitleTap(500)) - access.lock() - repeat(4) { assertFalse(access.onTitleTap(600L + it * 100)) } - assertTrue(access.onTitleTap(1_000)) - } - - @Test fun preferenceSurvivesNewStoreAndLockedAccessWithoutBeingReset() { - val values = mutableMapOf() - val prefs = mockk() - val editor = mockk() - every { prefs.getBoolean(any(), any()) } answers { values[firstArg()] ?: secondArg() } - every { prefs.edit() } returns editor - every { editor.putBoolean(any(), any()) } answers { values[firstArg()] = secondArg(); editor } - every { editor.apply() } returns Unit - assertFalse(DeveloperOptionsPreferences(prefs).receiveAutoRecoveryEnabled()) - DeveloperOptionsPreferences(prefs).setReceiveAutoRecoveryEnabled(false) - assertFalse(DeveloperOptionsAccess().onTitleTap(0)) - assertFalse(DeveloperOptionsPreferences(prefs).receiveAutoRecoveryEnabled()) - DeveloperOptionsPreferences(prefs).setReceiveAutoRecoveryEnabled(true) - assertTrue(DeveloperOptionsPreferences(prefs).receiveAutoRecoveryEnabled()) - assertEquals(setOf("receive_auto_recovery"), values.keys) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt deleted file mode 100644 index a16bc37..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt +++ /dev/null @@ -1,721 +0,0 @@ -package com.dct.app.tests - -import android.os.Environment -import com.dct.app.data.repository.DpcProjectManager -import com.dct.app.domain.model.mdb.CompensationMap -import com.dct.app.domain.model.mdb.CompensationMapType -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.MdbConstant -import com.dct.app.domain.model.mdb.MdbDatabase -import com.dct.app.domain.model.mdb.SignalDefinition -import com.dct.app.domain.model.mdb.SignalGroup -import com.dct.app.domain.model.project.DpcProject -import io.mockk.every -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import java.io.File -import org.json.JSONObject -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir - -class DpcProjectManagerTest { - @Test - fun csvMillisecondsDefaultFalseAndProjectSaveRestorePreservesThePcKey() { - assertFalse(DpcProject().csvWithMilliseconds) - assertFalse(DpcProjectManager.parseProjectFromJson("{}", null).csvWithMilliseconds) - val legacy = DpcProjectManager.parseProjectFromJson("{\"android_log_microseconds\":true}", null) - assertFalse(legacy.csvWithMilliseconds) - assertFalse(JSONObject(DpcProjectManager.serializeProjectToJson(legacy)).has("android_log_microseconds")) - listOf(false, true).forEach { enabled -> - val json = DpcProjectManager.serializeProjectToJson(DpcProject(csvWithMilliseconds = enabled)) - assertEquals(enabled, JSONObject(json).getBoolean("csv_with_miliseconds")) - assertEquals(enabled, DpcProjectManager.parseProjectFromJson(json, null).csvWithMilliseconds) - } - } - - @Test - fun pcProjectNameOmitsDpcExtensionWithoutChangingTheStoredFileName() { - val project = DpcProject(projectFileName = "example.dpc") - val json = JSONObject(DpcProjectManager.serializeProjectToJson(project)) - assertEquals("example", json.getString("name")) - assertEquals("example.dpc", project.projectFileName) - } - @Test - @DisplayName("P3 ask_info가 저장과 복원에서 보존되어야 한다") - fun testAskInfoRoundtrip() { - val project = DpcProject( - askInfo = com.dct.app.domain.model.project.DpcAskInfo( - firmwareAddress = "1234ABCD", - stMin = 5, - seedKeyPath = "vendor.so", - dependsOnRom = true - ) - ) - - val parsed = DpcProjectManager.parseProjectFromJson( - DpcProjectManager.serializeProjectToJson(project), - null - ) - - assertEquals(project.askInfo, parsed.askInfo) - } - - @Test - @DisplayName("DPC 프로젝트 직렬화 시 PC C++ 호환 27개 루트 키 및 프로퍼티 구조가 완벽히 생성되어야 한다") - fun testSerializeProjectToJson_FullPcCompatibility() { - val signals = listOf( - SignalDefinition( - reclid = 1, - idx = 1, - name = "Engine_Speed", - attr = MdbAttrType.ATTR_2_UINT16, - cvsA = 1.0, - cvsB = 0.0, - unit = "rpm", - color = 0x00FF0000L, - min = 0.0, - max = 8000.0, - description = "실시간 RPM", - adl = 100 - ) - ) - val constants = listOf( - MdbConstant( - idx = 10, - name = "Target_Idle_RPM", - data = 750.0, - addr = 200L, - min = 500.0, - max = 1200.0, - attr = MdbAttrType.ATTR_2_UINT16, - description = "목표 아이들 RPM" - ) - ) - val compA = listOf( - CompensationMap( - type = CompensationMapType.TYPE_A, - idx = 1, - yName = "Idle_Base_Duty", - yAddr = 310L, - yAttr = MdbAttrType.ATTR_2_UINT16, - yMin = 0.0, - yMax = 100.0, - xName = "Coolant_Temp", - xAddr = 300L, - xAttr = MdbAttrType.ATTR_2_UINT16, - cvsA = 1.0, - cvsB = 0.0, - description = "냉각수온 보정" - ) - ) - - val mdbDb = MdbDatabase( - groups = listOf(SignalGroup(reclid = 1, strIndex = "1", description = "Engine", usage = true, signals = signals)), - constants = constants, - compensationMaps = compA - ) - - val canSignals = listOf( - SignalDefinition( - reclid = 2, - idx = 1, - name = "CAN_Vehicle_Speed", - attr = MdbAttrType.ATTR_2_UINT16, - cvsA = 1.0, - cvsB = 0.0, - unit = "km/h", - color = 0x0000FF00L, - min = 0.0, - max = 250.0, - description = "차량 차속", - adl = 50 - ) - ) - - val a2lSignals = listOf( - SignalDefinition( - reclid = 3, - idx = 1, - name = "A2L_Battery_Voltage", - attr = MdbAttrType.ATTR_2_UINT16, - cvsA = 0.1, - cvsB = 0.0, - unit = "V", - color = 0x000000FFL, - min = 0.0, - max = 18.0, - description = "배터리 전압", - adl = 60 - ) - ) - - val project = DpcProject( - projectFileName = "TEST_PROJECT", - vehicleCode = "GENESIS_G90", - mdbFileName = "Engine_Data.mdb", - canDbcFileName = "Vehicle_CAN.dbc", - a2lFileName = "ECU_Firmware.a2l", - writeCsv = true, - writeMf4 = false, - canMode = "CAN(HiSpeed)", - forOldModel = false, - mdbDatabase = mdbDb, - canSignals = canSignals, - a2lSignals = a2lSignals - ) - - val jsonStr = DpcProjectManager.serializeProjectToJson(project) - val json = JSONObject(jsonStr) - - // 1. Root Keys 검증 - assertEquals("TEST_PROJECT", json.getString("name")) - assertEquals("GENESIS_G90", json.getString("cartype")) - assertEquals(1, json.getInt("canmode")) - assertEquals(0, json.getInt("oldcanmode")) - assertEquals(1, json.getInt("writecsv")) - assertEquals(0, json.getInt("writemf4")) - assertTrue(json.has("base_folder")) - assertTrue(json.has("DID_INFO")) - assertTrue(json.has("hw_device")) - assertTrue(json.has("ask_info")) - assertTrue(json.has("ota_info")) - assertTrue(json.has("view_rect")) - assertTrue(json.has("graphItem_property")) - - // 2. external_file 검증 (4개 엔트리) - val extFiles = json.getJSONArray("external_file") - assertEquals(4, extFiles.length()) - val mdbExt = extFiles.getJSONObject(0) - assertEquals("mdb", mdbExt.getString("ext")) - assertEquals("\\MDB\\Engine_Data.mdb", mdbExt.getString("path")) - assertEquals(1, mdbExt.getInt("mdbfile_path_use_basefolder")) - - val dbcExt = extFiles.getJSONObject(1) - assertEquals("dbc", dbcExt.getString("ext")) - assertEquals("\\DBC\\Vehicle_CAN.dbc", dbcExt.getString("path")) - assertEquals(1, dbcExt.getInt("dbcfile_path_use_basefolder")) - - // 3. candbc_property 검증 - assertTrue(json.has("candbc_property")) - val canProp = json.getJSONObject("candbc_property").getJSONArray("property").getJSONObject(0) - val canItems = canProp.getJSONArray("items") - assertEquals("CAN_Vehicle_Speed", canItems.getJSONObject(0).getString("Name")) - assertEquals(0x0000FF00L, canItems.getJSONObject(0).getLong("USER_COLOR")) - assertEquals("차량 차속", canItems.getJSONObject(0).getString("Descrip")) - - // 4. mdb_recv_property 검증 - assertTrue(json.has("mdb_recv_property")) - val mdbRecv = json.getJSONObject("mdb_recv_property").getJSONArray("property") - val sigGroup = mdbRecv.getJSONObject(0) - assertEquals("MDB Signal", sigGroup.getString("name")) - val sigItems = sigGroup.getJSONArray("items") - assertEquals("Engine_Speed", sigItems.getJSONObject(0).getString("Name")) - assertEquals(0x00FF0000L, sigItems.getJSONObject(0).getLong("USER_COLOR")) - assertEquals("실시간 RPM", sigItems.getJSONObject(0).getString("Descrip")) - - val conGroup = mdbRecv.getJSONObject(1) - assertEquals("Constant Signal", conGroup.getString("name")) - val conItems = conGroup.getJSONArray("items") - assertEquals("Target_Idle_RPM", conItems.getJSONObject(0).getString("Name")) - assertEquals(750.0, conItems.getJSONObject(0).getDouble("Value")) - - // 5. mdb_compensation_A_property 검증 - assertTrue(json.has("mdb_compensation_A_property")) - val compAProp = json.getJSONObject("mdb_compensation_A_property").getJSONArray("items") - assertEquals("Coolant_Temp", compAProp.getJSONObject(0).getString("XNAME")) - assertEquals("Idle_Base_Duty", compAProp.getJSONObject(0).getString("YNAME")) - - // 6. a2l_property 검증 - assertTrue(json.has("a2l_property")) - val a2lItems = json.getJSONObject("a2l_property").getJSONObject("measurement").getJSONArray("items") - assertEquals("A2L_Battery_Voltage", a2lItems.getJSONObject(0).getString("Name")) - assertEquals(0x000000FFL, a2lItems.getJSONObject(0).getLong("USER_COLOR")) - assertEquals("배터리 전압", a2lItems.getJSONObject(0).getString("Descrip")) - } - - @Test - @DisplayName("PC 원본 .dpc JSON을 로드하여 파싱하고 다시 저장할 때 원본 속성이 100% 보존되어야 한다") - fun testParseAndRoundtripPcJson() { - val samplePcJson = """ - { - "DID_INFO" : { - "DIDF100PN2" : "", - "DIDF187PN" : "", - "DIDF18BPD" : "", - "DIDF191HW" : "", - "DIDF193SHW" : "", - "DIDF197SN" : "", - "DIDF1A1SC" : "", - "DIDPreSetting" : 49 - }, - "ask_info" : { - "ASKFWAddr" : "", - "ASKSTMin" : 0, - "seedKeyPath" : "" - }, - "autostart_measuring_recording" : 0, - "base_folder" : "C:\\Users\\COMPUTER1\\Documents\\DCT Work", - "candbc_property" : { - "property" : [ - { - "id" : 0, - "items" : [ - { - "Descrip" : "엔진 속도", - "Name" : "Engine_RPM", - "USER_COLOR" : 65280, - "item_dashstyle" : 0, - "item_thickness" : 2 - } - ], - "name" : "CAN Signal" - } - ] - }, - "canmode" : 1, - "cartype" : "NQ5", - "copy_savedfolder" : "C:\\Users\\COMPUTER1\\Documents\\DCT Work\\Data", - "external_file" : [ - { - "ext" : "mdb", - "mdbfile_path_use_basefolder" : 1, - "path" : "\\MDB\\NQ5_Seat.mdb" - }, - { - "dbcfile_path_use_basefolder" : 0, - "ext" : "dbc", - "path" : "C:\\CanDb.dbc" - }, - { - "ext" : "smf", - "path" : "", - "smffile_path_use_basefolder" : 0 - }, - { - "a2lfile_path_use_basefolder" : 0, - "ext" : "a2l", - "path" : "" - } - ], - "graphItem_property" : { - "graph_linear" : true - }, - "hw_device" : { - "com_port" : "", - "connectedType" : 1, - "host_address" : "", - "macaddr" : "", - "sm_count" : 0 - }, - "isave_logfile" : 1, - "layout_wnd" : { - "items" : [], - "layoutcnt" : 0 - }, - "logfile_path" : "\\Data", - "logfile_path_use_basefolder" : 1, - "maker" : 2, - "name" : "PROJECT", - "oldcanmode" : 0, - "version" : "0.0.0.2", - "writecsv" : 1, - "writemf4" : 0 - } - """.trimIndent() - - val parsed = DpcProjectManager.parseProjectFromJson(samplePcJson, null) - assertEquals("PROJECT", parsed.projectFileName) - assertEquals("NQ5", parsed.vehicleCode) - assertEquals("NQ5_Seat.mdb", parsed.mdbFileName) - assertEquals("CanDb.dbc", parsed.canDbcFileName) - assertEquals("CAN(HiSpeed)", parsed.canMode) - assertEquals(true, parsed.writeCsv) - assertEquals(65280L, parsed.canSignalColors["Engine_RPM"]) - assertEquals("엔진 속도", parsed.canSignalDescriptions["Engine_RPM"]) - assertNotNull(parsed.rawJsonRoot) - - // 수정 후 재직렬화 - val modified = parsed.copy(vehicleCode = "NQ5_FACELIFT") - val reSerialized = DpcProjectManager.serializeProjectToJson(modified) - val reJson = JSONObject(reSerialized) - - assertEquals("NQ5_FACELIFT", reJson.getString("cartype")) - assertEquals("PROJECT", reJson.getString("name")) - assertEquals(1, reJson.getInt("canmode")) - assertEquals(1, reJson.getInt("writecsv")) - assertTrue(reJson.has("DID_INFO")) - assertTrue(reJson.has("candbc_property")) - assertTrue(reJson.has("layout_wnd")) - } - - @Test - @DisplayName("하드코딩 없는 동적 base_folder 및 use_basefolder 상대 경로 직렬화/역직렬화가 정상 동작해야 한다") - fun testDynamicBaseFolderAndUseBaseFolderResolution() { - val proj = DpcProject( - projectFileName = "CustomTest", - baseFolder = "D:\\MyWorks\\CustomDCT", - mdbFileName = "MyEcu.mdb", - canDbcFileName = "MyCan.dbc", - a2lFileName = "MyA2l.a2l", - measuredFilesFolder = "D:\\MyWorks\\CustomDCT\\Data" - ) - - val jsonStr = DpcProjectManager.serializeProjectToJson(proj) - val json = JSONObject(jsonStr) - - assertEquals("D:\\MyWorks\\CustomDCT", json.getString("base_folder")) - assertEquals("\\Data", json.getString("logfile_path")) - assertEquals(1, json.getInt("logfile_path_use_basefolder")) - - val extFiles = json.getJSONArray("external_file") - var mdbObj: JSONObject? = null - var dbcObj: JSONObject? = null - var a2lObj: JSONObject? = null - for (i in 0 until extFiles.length()) { - val item = extFiles.getJSONObject(i) - when (item.getString("ext")) { - "mdb" -> mdbObj = item - "dbc" -> dbcObj = item - "a2l" -> a2lObj = item - } - } - - assertNotNull(mdbObj) - assertEquals("\\MDB\\MyEcu.mdb", mdbObj!!.getString("path")) - assertEquals(1, mdbObj.getInt("mdbfile_path_use_basefolder")) - - assertNotNull(dbcObj) - assertEquals("\\DBC\\MyCan.dbc", dbcObj!!.getString("path")) - assertEquals(1, dbcObj.getInt("dbcfile_path_use_basefolder")) - - assertNotNull(a2lObj) - assertEquals("\\A2L\\MyA2l.a2l", a2lObj!!.getString("path")) - assertEquals(1, a2lObj.getInt("a2lfile_path_use_basefolder")) - - // 역직렬화 검증 - val parsed = DpcProjectManager.parseProjectFromJson(jsonStr, null) - assertEquals("D:\\MyWorks\\CustomDCT", parsed.baseFolder) - assertEquals("MyEcu.mdb", parsed.mdbFileName) - assertEquals("MyCan.dbc", parsed.canDbcFileName) - assertEquals("MyA2l.a2l", parsed.a2lFileName) - } - - @Test - @DisplayName("createDefaultProject 호출 시 안드로이드 Documents/DCT Work/Data 경로가 기본값으로 고정되고 projectFileName은 빈칸이어야 한다") - fun testDefaultMeasuredFolderAndCreateDefaultProject() { - val proj = DpcProjectManager.createDefaultProject() - assertEquals("", proj.projectFileName) - assertNotNull(proj.baseFolder) - assertTrue(proj.baseFolder.contains("DCT Work")) - assertNotNull(proj.measuredFilesFolder) - assertTrue(proj.measuredFilesFolder.contains("DCT Work") && proj.measuredFilesFolder.contains("Data")) - - val jsonStr = DpcProjectManager.serializeProjectToJson(proj) - val json = JSONObject(jsonStr) - assertTrue(json.has("base_folder")) - assertTrue(json.has("logfile_path")) - } - - @Test - @DisplayName("DID 정보 및 Sensor Module 1~4 설정이 .dpc JSON으로 정확히 직렬화되고 역직렬화되어야 한다") - fun testDidInfoAndSensorModulesSerializationAndDeserialization() { - val did = com.dct.app.domain.model.project.DpcDidInfo( - partName2 = "GENESIS_ECU_V2", - ecuHwVersion = "H010", - partNumber = "391102B000", - productDate = "20260825", - supplierCode = "MOBI", - systemName = "DCTS", - supplierHwVersion = "S001" - ) - val smList = com.dct.app.domain.model.project.createDefaultSensorModules().toMutableList() - // SM 1 채널 1을 Voltage로 변경하고 Description 설정 - val sm0Ch = smList[0].channels.toMutableList() - sm0Ch[0] = sm0Ch[0].copy(isVoltage = true, unit = "V", description = "배터리 전압 센서") - smList[0] = smList[0].copy(isUsed = true, channels = sm0Ch) - - val project = com.dct.app.domain.model.project.DpcProject( - projectFileName = "TEST_ADVANCED", - writeCsv = true, - writeMf4 = false, - canMode = "CAN(FD)", - forOldModel = true, - didInfo = did, - sensorModules = smList - ) - - val jsonStr = DpcProjectManager.serializeProjectToJson(project) - val json = JSONObject(jsonStr) - - // 1. DID_INFO 키 검증 - assertTrue(json.has("DID_INFO")) - val didObj = json.getJSONObject("DID_INFO") - assertEquals("GENESIS_ECU_V2", didObj.getString("DIDF100PN2")) - assertEquals("H010", didObj.getString("DIDF191HW")) - assertEquals("391102B000", didObj.getString("DIDF187PN")) - assertEquals("20260825", didObj.getString("DIDF18BPD")) - assertEquals("MOBI", didObj.getString("DIDF1A1SC")) - assertEquals("DCTS", didObj.getString("DIDF197SN")) - assertEquals("S001", didObj.getString("DIDF193SHW")) - - // 2. sensormodule_property 키 검증 (PC C++ EnvSetting.cpp 표준 스키마) - assertTrue(json.has("sensormodule_property")) - val smArr = json.getJSONObject("sensormodule_property").getJSONArray("property") - assertEquals(4, smArr.length()) - val sm0 = smArr.getJSONObject(0) - assertEquals(1, sm0.getInt("sm_use")) - val ch0 = sm0.getJSONArray("items").getJSONObject(0) - assertEquals(1, ch0.getInt("VOLTAGE")) - assertEquals("배터리 전압 센서", ch0.getString("Descrip")) - - // 3. CAN mode 및 Logging 플래그 검증 - assertEquals(0, json.getInt("canmode")) // CAN(FD) -> 0 - assertEquals(1, json.getInt("oldcanmode")) // For old model -> 1 - assertEquals(1, json.getInt("writecsv")) - assertEquals(0, json.getInt("writemf4")) - - // 4. 역직렬화 검증 - val parsed = DpcProjectManager.parseProjectFromJson(jsonStr, null) - assertEquals("GENESIS_ECU_V2", parsed.didInfo.partName2) - assertEquals("391102B000", parsed.didInfo.partNumber) - assertEquals("CAN(FD)", parsed.canMode) - assertTrue(parsed.forOldModel) - assertTrue(parsed.writeCsv) - assertFalse(parsed.writeMf4) - assertEquals(4, parsed.sensorModules.size) - assertTrue(parsed.sensorModules[0].channels[0].isVoltage) - assertEquals("배터리 전압 센서", parsed.sensorModules[0].channels[0].description) - } - - @Test - @DisplayName("기존 안드로이드 키(sm_property 및 formula_info)가 포함된 JSON도 완벽히 역직렬화되어야 한다") - fun testLegacyAndroidKeysCompatibility() { - val legacyJson = """ - { - "name": "LEGACY_ANDROID", - "cartype": "NQ5", - "formula_info": { - "items": [ - { - "Name": "CalcPower", - "Expression": "\"Torque\" * \"Speed\" / 9549", - "Unit": "kW", - "Color": 16711680, - "IsForSM": 0, - "Descrip": "출력 계산식" - } - ] - }, - "sm_property": { - "modules": [ - { - "index": 0, - "is_used": 1, - "channels": [ - { - "index": 0, - "name": "01-01 ch", - "is_voltage": 1, - "unit": "V", - "description": "배터리 전압", - "color": 65280 - } - ] - } - ] - } - } - """.trimIndent() - - val parsed = DpcProjectManager.parseProjectFromJson(legacyJson, null) - assertEquals("LEGACY_ANDROID", parsed.projectFileName) - assertEquals("NQ5", parsed.vehicleCode) - - // Formula 검증 - assertEquals(1, parsed.formulas.size) - val f = parsed.formulas[0] - assertEquals("CalcPower", f.name) - assertEquals("\"Torque\" * \"Speed\" / 9549", f.expression) - assertEquals("kW", f.unit) - assertFalse(f.isForSm) - assertEquals("출력 계산식", f.description) - - // SM 검증 - assertTrue(parsed.sensorModules[0].isUsed) - val ch0 = parsed.sensorModules[0].channels[0] - assertEquals(0, ch0.channelIndex) - assertEquals("01-01 ch", ch0.name) - assertTrue(ch0.isVoltage) - assertEquals("V", ch0.unit) - assertEquals("배터리 전압", ch0.description) - } - - @Test - @DisplayName("PC C++ 표준 formula_property 및 sensormodule_property 직렬화 및 양방향 라운드트립이 보장되어야 한다") - fun testPcFormulaAndSensorModuleRoundtrip() { - val formulas = listOf( - com.dct.app.domain.model.formula.FormulaDefinition( - name = "EnginePower", - expression = "\"Torque\" * \"EngineSpeed\" / 9549", - unit = "kW", - color = 0x00FF0000L, - isForSm = false, - description = "엔진 출력 수식" - ), - com.dct.app.domain.model.formula.FormulaDefinition( - name = "SmTempComp", - expression = "LIN(\"01-01\"; 0; 0; 5; 100)", - unit = "'C", - color = 0x0000FF00L, - isForSm = true, - description = "센서모듈 온도 보정" - ) - ) - - val smList = com.dct.app.domain.model.project.createDefaultSensorModules().toMutableList() - val sm0Ch = smList[0].channels.toMutableList() - sm0Ch[0] = sm0Ch[0].copy(name = "01-01 Custom", isVoltage = false, unit = "'C", description = "온도 센서") - smList[0] = smList[0].copy(isUsed = true, channels = sm0Ch) - - val project = DpcProject( - projectFileName = "PC_COMPAT_TEST", - vehicleCode = "RG3", - formulas = formulas, - sensorModules = smList - ) - - val jsonStr = DpcProjectManager.serializeProjectToJson(project) - val json = JSONObject(jsonStr) - - // PC C++ 키 존재 및 내부 스키마 검증 - assertTrue(json.has("formula_property")) - assertFalse(json.has("formula_info")) // 기존 키는 깔끔히 정리 - val fItems = json.getJSONObject("formula_property").getJSONArray("items") - assertEquals(2, fItems.length()) - assertEquals("EnginePower", fItems.getJSONObject(0).getString("name")) - assertEquals("\"Torque\" * \"EngineSpeed\" / 9549", fItems.getJSONObject(0).getString("expression")) - assertFalse(fItems.getJSONObject(0).getBoolean("forSM")) - assertEquals(0, fItems.getJSONObject(0).getInt("index")) - assertTrue(fItems.getJSONObject(1).getBoolean("forSM")) - - assertTrue(json.has("sensormodule_property")) - assertFalse(json.has("sm_property")) // 기존 키는 깔끔히 정리 - val smPropArr = json.getJSONObject("sensormodule_property").getJSONArray("property") - val sm0Obj = smPropArr.getJSONObject(0) - assertEquals(0, sm0Obj.getInt("sm_index")) - assertEquals(1, sm0Obj.getInt("sm_use")) - val chItems = sm0Obj.getJSONArray("items") - assertEquals("01-01 Custom", chItems.getJSONObject(0).getString("name")) - assertEquals(0, chItems.getJSONObject(0).getInt("VOLTAGE")) - assertEquals("온도 센서", chItems.getJSONObject(0).getString("Descrip")) - - // 역직렬화 라운드트립 검증 - val parsed = DpcProjectManager.parseProjectFromJson(jsonStr, null) - assertEquals(2, parsed.formulas.size) - assertEquals("EnginePower", parsed.formulas[0].name) - assertFalse(parsed.formulas[0].isForSm) - assertEquals("SmTempComp", parsed.formulas[1].name) - assertTrue(parsed.formulas[1].isForSm) - assertEquals("'C", parsed.formulas[1].unit) - - assertTrue(parsed.sensorModules[0].isUsed) - assertFalse(parsed.sensorModules[0].channels[0].isVoltage) - assertEquals("01-01 Custom", parsed.sensorModules[0].channels[0].name) - assertEquals("온도 센서", parsed.sensorModules[0].channels[0].description) - } - - @Test - @DisplayName("기본 6개 폴더만 만들고 미사용 Screen/SMC는 반복 초기화에도 생성하지 않는다") - fun testEnsureDctWorkDirectoriesSkipsUnusedFolders(@TempDir documents: File) { - mockkStatic(Environment::class) - try { - every { Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) } returns documents - val baseDir = DpcProjectManager.ensureDctWorkDirectories() - assertEquals(File(documents, "DCT Work"), baseDir) - DpcProjectManager.ensureDctWorkDirectories() - for (sub in listOf("Configuration", "Data", "MDB", "DBC", "A2L", "Log")) { - assertTrue(File(baseDir, sub).isDirectory, "서브폴더 $sub 가 자동 생성되어야 합니다.") - } - assertFalse(File(baseDir, "Screen").exists()) - assertFalse(File(baseDir, "SMC").exists()) - } finally { - unmockkStatic(Environment::class) - } - } - - @Test - @DisplayName("기존 Screen/SMC 폴더와 파일은 초기화 시 삭제하거나 덮어쓰지 않는다") - fun testEnsureDctWorkDirectoriesPreservesExistingUnusedFolders(@TempDir documents: File) { - val base = File(documents, "DCT Work") - val screenFolder = File(base, "Screen").also { assertTrue(it.mkdirs()) } - val smcFolder = File(base, "SMC").also { assertTrue(it.mkdirs()) } - val screenFile = File(screenFolder, "screen.keep").apply { writeText("existing screen") } - val smcFile = File(smcFolder, "preset.smc").apply { writeText("existing sensor config") } - mockkStatic(Environment::class) - try { - every { Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) } returns documents - assertEquals(base, DpcProjectManager.ensureDctWorkDirectories()) - DpcProjectManager.ensureDctWorkDirectories() - assertEquals("existing screen", screenFile.readText()) - assertEquals("existing sensor config", smcFile.readText()) - assertEquals(listOf("screen.keep"), screenFolder.list()?.toList()) - assertEquals(listOf("preset.smc"), smcFolder.list()?.toList()) - } finally { - unmockkStatic(Environment::class) - } - } - - @Test - @DisplayName("파일 기반 프로젝트 저장 및 loadProjectFromFile 역직렬화 검증") - fun testSaveAndLoadProjectFromFile() { - val tempDir = java.io.File(System.getProperty("java.io.tmpdir"), "dct_test_config") - tempDir.mkdirs() - val tempFile = java.io.File(tempDir, "TEST_CONFIG_PROJ.dpc") - - val project = DpcProject( - projectFileName = "TEST_CONFIG_PROJ", - vehicleCode = "DN8", - mdbFileName = "DN8_Engine.mdb", - canDbcFileName = "DN8_CAN.dbc", - canMode = "CAN(HiSpeed)", - writeCsv = true - ) - val json = DpcProjectManager.serializeProjectToJson(project) - tempFile.writeText(json, Charsets.UTF_8) - - val loaded = DpcProjectManager.loadProjectFromFile(tempFile) - assertNotNull(loaded) - assertEquals("TEST_CONFIG_PROJ.dpc", loaded!!.projectFileName) - assertEquals("DN8", loaded.vehicleCode) - assertEquals("DN8_Engine.mdb", loaded.mdbFileName) - assertEquals("DN8_CAN.dbc", loaded.canDbcFileName) - assertEquals(tempFile.absolutePath, loaded.localProjectPath) - - tempFile.delete() - tempDir.delete() - } - - @Test - @DisplayName("isProjectFileExists 및 getFilesByExtension 동작 검증") - fun testIsProjectFileExistsAndGetFilesByExtension() { - val configDir = java.io.File(DpcProjectManager.getDefaultConfigurationFolder()) - configDir.mkdirs() - val testFile = java.io.File(configDir, "EXISTS_TEST_PROJ.dpc") - testFile.writeText("{}", Charsets.UTF_8) - - assertTrue(DpcProjectManager.isProjectFileExists(null, "EXISTS_TEST_PROJ")) - assertTrue(DpcProjectManager.isProjectFileExists(null, "EXISTS_TEST_PROJ.dpc")) - assertFalse(DpcProjectManager.isProjectFileExists(null, "NON_EXISTING_PROJ_999")) - - val foundFiles = DpcProjectManager.getFilesByExtension(".dpc", "Configuration", null) - assertTrue(foundFiles.any { it.name.equals("EXISTS_TEST_PROJ.dpc", ignoreCase = true) }) - - testFile.delete() - assertFalse(DpcProjectManager.isProjectFileExists(null, "EXISTS_TEST_PROJ")) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt b/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt deleted file mode 100644 index dda4ffd..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.repository.DpcProjectManager -import com.dct.app.data.repository.DpcWorkspaceJson -import com.dct.app.core.theme.PcColorRef -import com.dct.app.domain.model.chart.* -import com.dct.app.domain.model.project.* -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.json.JSONObject -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class DpcWorkspacePersistenceTest { - private fun sample(): DpcWorkspaceState { - val signal = DpcSignalBindingState("sig_1_2", "RPM", AxisPosition.RIGHT_1, PcColorRef.fromRgb(0xAA, 0xBB, 0xCC), - "rpm", 4f, 3, false, InterpolationMode.PWM_STEP) - return DpcWorkspaceState("page_2", listOf( - DpcPageState("page_1", "Engine", windows = listOf( - DpcWindowState("graph", "MULTI_AXIS_GRAPH", "Engine Graph", x = 730f, y = 90f, - width = 650f, height = 420f, zIndex = 3f, isMinimized = true, createdAt = 42, - signals = listOf(signal), chart = DpcChartState( - axes = listOf(ChartAxisConfig(AxisPosition.RIGHT_1, -10.0, 9000.0, false, label = "RPM")), - timeWindowSec = 17.0, timeWindowPreset = TimeWindowPreset.MANUAL, - isXAxisAuto = false, isYAxisAuto = false, legendPosition = 2 - )), - DpcWindowState("alpha", "ALPHANUMERICAL", "Values", targetSignals = listOf("sig_1_2", "can_4"), - isTileMode = false, hiddenSignalIds = setOf("RPM"), highlightColors = mapOf("RPM" to PcColorRef.fromRgb(0xFF, 0x00, 0x00))) - ), activeWindowId = "graph", scrollX = 250f, scrollY = 60f, signals = listOf(signal), - alphanumericalSignals = listOf("can_4"), mdbSearchQuery = "speed", canSearchQuery = "engine"), - DpcPageState("page_2", "Intentionally empty") - )) - } - - @Test - fun workspaceRoundTripPreservesAllFieldsAndPcKeys() { - val workspace = sample() - val root = JSONObject("""{"layout_wnd":{"items":[{"opaque":"AABBCC"}]},"vendor_custom":{"keep":true}}""") - val encoded = DpcProjectManager.serializeProjectToJson(DpcProject(workspace = workspace, rawJsonRoot = root)) - val restored = DpcProjectManager.parseProjectFromJson(encoded, null) - assertEquals(workspace, restored.workspace) - assertEquals(root.getJSONObject("layout_wnd").toString(), restored.rawJsonRoot!!.getJSONObject("layout_wnd").toString()) - assertTrue(restored.rawJsonRoot!!.getJSONObject("vendor_custom").getBoolean("keep")) - assertFalse(encoded.contains("buffer")) - assertFalse(encoded.contains("isConnected")) - } - - @Test - fun legacyAndFutureExtensionsAreSafe() { - assertNull(DpcProjectManager.parseProjectFromJson("{}", null).workspace) - val future = JSONObject("""{"android_workspace":{"schema_version":99,"future":"untouched"}}""") - val parsed = DpcProjectManager.parseProjectFromJson(future.toString(), null) - assertNull(parsed.workspace) - val saved = JSONObject(DpcProjectManager.serializeProjectToJson(parsed.copy(workspace = sample()))) - assertEquals(future.getJSONObject(DpcWorkspaceJson.KEY).toString(), saved.getJSONObject(DpcWorkspaceJson.KEY).toString()) - } - - @Test - fun malformedFieldsCannotBreakRemainingWorkspace() { - val json = DpcWorkspaceJson.encode(sample()) - json.put("active_page_id", "missing") - val page = json.getJSONArray("pages").getJSONObject(0) - val window = page.getJSONArray("windows").getJSONObject(0) - window.put("width", -1).put("x", "NaN") - val signal = window.getJSONArray("signals").getJSONObject(0) - signal.put("axis", "NEW_AXIS").put("line_width", "Infinity") - val axis = window.getJSONObject("chart").getJSONArray("axes").getJSONObject(0) - axis.put("min", 100).put("max", -100) - val decoded = DpcWorkspaceJson.decode(json)!! - assertEquals("page_1", decoded.activePageId) - val restored = decoded.pages.first().windows.first() - assertEquals(120f, restored.width) - assertEquals(20f, restored.x) - assertEquals(AxisPosition.LEFT_0, restored.signals.first().axisPosition) - assertEquals(2f, restored.signals.first().lineWidthDp) - assertEquals(100.0, restored.chart!!.axes.first().maxVal) - assertEquals(2, decoded.pages.size) - } - - @Test - fun rendererRestoresStylesAndAxesWithoutTelemetryOrSharedBuffers() { - val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) - val saved = sample().pages.first().windows.first() - val series = saved.signals.first().toSeries() - assertEquals(1, series.buffer.capacity) - series.buffer.addSample(1.0, 123.0) - renderer.restoreWorkspaceState(listOf(series), saved.chart) - assertNull(renderer.seriesListFlow.value.first().buffer.getLatestTimestamp()) - assertNotSame(series.buffer, renderer.seriesListFlow.value.first().buffer) - assertEquals(saved.signals, renderer.seriesListFlow.value.map(DpcSignalBindingState::fromSeries)) - assertEquals(saved.chart!!.axes.first(), renderer.exportWorkspaceState().axes.find { it.position == AxisPosition.RIGHT_1 }) - assertEquals(17.0, renderer.exportWorkspaceState().timeWindowSec) - renderer.setSeriesDashStyle(series.signalId, 4) - renderer.setLegendPosition(1) - assertEquals(4, renderer.seriesListFlow.value.first().dashStyle) - assertEquals(1, renderer.exportWorkspaceState().legendPosition) - } - - @Test - fun rendererKeepsInsertionOrderAcrossRestores() { - val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) - val series = listOf("z", "a", "m").map { ChartSignalSeries(it, it, AxisPosition.LEFT_0, PcColorRef.fromRgb(0x11, 0x22, 0x33)) } - renderer.restoreWorkspaceState(series, null) - assertEquals(listOf("z", "a", "m"), renderer.buildSnapshot(100).seriesList.map { it.signalId }) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/FirmwareFileParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/FirmwareFileParserTest.kt deleted file mode 100644 index 056e44c..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/FirmwareFileParserTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.maintenance.FirmwareFileParser -import com.dct.app.domain.model.maintenance.FirmwareFormat -import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Test - -class FirmwareFileParserTest { - @Test - fun `intel hex validates checksum and fills address gaps with FF`() { - val source = listOf( - ":020000040001F9", - ":020010001122BB", - ":0100130033B9", - ":00000001FF" - ).joinToString("\n").toByteArray() - - val image = FirmwareFileParser.parse("ecu.hex", source) - - assertEquals(FirmwareFormat.INTEL_HEX, image.format) - assertEquals(0x10010L, image.startAddress) - assertEquals(2 * 1024 * 1024, image.bytes.size) - assertArrayEquals(byteArrayOf(0x11, 0x22, 0xFF.toByte(), 0x33), image.bytes.copyOfRange(0, 4)) - assertEquals(0xFF.toByte(), image.bytes.last()) - } - - @Test - fun `s19 validates checksum and fills address gaps with zero`() { - val source = listOf( - "S10510001122B7", - "S104100333B5", - "S9031000EC" - ).joinToString("\n").toByteArray() - - val image = FirmwareFileParser.parse("ecu.s19", source) - - assertEquals(FirmwareFormat.MOTOROLA_S19, image.format) - assertEquals(0x1000L, image.startAddress) - assertArrayEquals(byteArrayOf(0x11, 0x22, 0x00, 0x33), image.bytes) - } - - @Test - fun `corrupt checksum is rejected`() { - val badHex = ":0100000001FF\n:00000001FF".toByteArray() - assertThrows(IllegalArgumentException::class.java) { - FirmwareFileParser.parse("bad.hex", badHex) - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaEvaluatorTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaEvaluatorTest.kt deleted file mode 100644 index 6bedf76..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/FormulaEvaluatorTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.formula.FormulaEvaluator -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import kotlin.math.PI - -class FormulaEvaluatorTest { - - @BeforeEach - fun setUp() { - FormulaEvaluator.clearCache() - } - - @Test - @DisplayName("기본 사칙연산 및 연산자 우선순위 검증") - fun testBasicArithmetic() { - val vars = emptyMap() - assertEquals(7.0, FormulaEvaluator.evaluate("1 + 2 * 3", vars), 0.0001) - assertEquals(9.0, FormulaEvaluator.evaluate("(1 + 2) * 3", vars), 0.0001) - assertEquals(3.5, FormulaEvaluator.evaluate("10 / 2 - 1.5", vars), 0.0001) - assertEquals(1.0, FormulaEvaluator.evaluate("10 % 3", vars), 0.0001) - assertEquals(8.0, FormulaEvaluator.evaluate("2 ^ 3", vars), 0.0001) - } - - @Test - @DisplayName("단항 연산자(Unary Minus/Plus) 검증") - fun testUnaryOperators() { - val vars = emptyMap() - assertEquals(-5.0, FormulaEvaluator.evaluate("-5", vars), 0.0001) - assertEquals(5.0, FormulaEvaluator.evaluate("-5 + 10", vars), 0.0001) - assertEquals(-5.0, FormulaEvaluator.evaluate("-(3 + 2)", vars), 0.0001) - assertEquals(2.0, FormulaEvaluator.evaluate("-3 + 5", vars), 0.0001) - assertEquals(-6.0, FormulaEvaluator.evaluate("2 * -3", vars), 0.0001) - assertEquals(0.125, FormulaEvaluator.evaluate("2 ^ -3", vars), 0.0001) - assertEquals(-4.0, FormulaEvaluator.evaluate("-2 ^ 2", vars), 0.0001) - } - - @Test - @DisplayName("수학 함수 및 상수(sin, cos, sqrt, abs, min, max, pow, pi) 검증") - fun testMathFunctionsAndConstants() { - val vars = emptyMap() - assertEquals(4.0, FormulaEvaluator.evaluate("sqrt(16)", vars), 0.0001) - assertEquals(42.0, FormulaEvaluator.evaluate("abs(-42)", vars), 0.0001) - assertEquals(0.0, FormulaEvaluator.evaluate("sin(0)", vars), 0.0001) - assertEquals(1.0, FormulaEvaluator.evaluate("cos(0)", vars), 0.0001) - assertEquals(10.0, FormulaEvaluator.evaluate("min(10, 20)", vars), 0.0001) - assertEquals(20.0, FormulaEvaluator.evaluate("max(10, 20)", vars), 0.0001) - assertEquals(16.0, FormulaEvaluator.evaluate("pow(2, 4)", vars), 0.0001) - assertEquals(PI, FormulaEvaluator.evaluate("pi", vars), 0.0001) - } - - @Test - @DisplayName("신호 변수 바인딩(큰따옴표 및 식별자) 및 복합 수식 연산 검증") - fun testVariableSubstitution() { - val telemetry = mapOf( - "EngineSpeed" to 3000.0, - "Torque" to 200.0, - "T_Inlet" to 85.5, - "T_Outlet" to 72.3 - ) - - // PC 레거시 큰따옴표 형식 - val powerKw = FormulaEvaluator.evaluate("\"Torque\" * \"EngineSpeed\" / 9549", telemetry) - assertEquals(3000.0 * 200.0 / 9549.0, powerKw, 0.001) - - // 온도차 수식 - val tempDiff = FormulaEvaluator.evaluate("\"T_Inlet\" - \"T_Outlet\"", telemetry) - assertEquals(13.2, tempDiff, 0.001) - - // 따옴표 없는 식별자 형식 - val halfSpeed = FormulaEvaluator.evaluate("EngineSpeed * 0.5", telemetry) - assertEquals(1500.0, halfSpeed, 0.001) - } - - @Test - @DisplayName("0 나누기 및 잘못된 문법에 대한 안전성(NaN / Validation) 검증") - fun testErrorHandling() { - val vars = emptyMap() - assertTrue(FormulaEvaluator.evaluate("10 / 0", vars).isNaN()) - assertTrue(FormulaEvaluator.evaluate("sqrt(-1)", vars).isNaN()) - assertTrue(FormulaEvaluator.evaluate("", vars).isNaN()) - assertTrue(FormulaEvaluator.evaluate("+++", vars).isNaN()) - - assertTrue(FormulaEvaluator.validate("1 + 2 * 3")) - assertTrue(FormulaEvaluator.validate("\"Torque\" * 2")) - assertFalse(FormulaEvaluator.validate("")) - assertFalse(FormulaEvaluator.validate("1 + * 2")) - assertFalse(FormulaEvaluator.validate("1 + 2)")) - assertFalse(FormulaEvaluator.validate("(1 + 2")) - assertFalse(FormulaEvaluator.validate("1 # + 2")) - assertFalse(FormulaEvaluator.validate("\"Torque * 2")) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaIntegrationTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaIntegrationTest.kt deleted file mode 100644 index f600e00..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/FormulaIntegrationTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.formula.FormulaEvaluator -import com.dct.app.data.logger.CsvMeasurementLogger -import com.dct.app.data.logger.Mf4MeasurementLogger -import com.dct.app.data.repository.DpcProjectManager -import com.dct.app.domain.model.formula.FormulaDefinition -import com.dct.app.domain.model.project.DpcProject -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File - -class FormulaIntegrationTest { - - @Test - @DisplayName("DpcProject JSON 직렬화 및 역직렬화 시 Formula 목록이 100% 보존되어야 한다") - fun testFormulaProjectSerialization() { - val f1 = FormulaDefinition( - name = "Power_kW", - expression = "\"Torque\" * \"EngineSpeed\" / 9549", - unit = "kW", - color = 0xFF2196F3L, - isForSm = false, - description = "엔진 출력 수식" - ) - val f2 = FormulaDefinition( - name = "Diff_Temp", - expression = "\"T_Inlet\" - \"T_Outlet\"", - unit = "'C", - color = 0xFFFF9800L, - isForSm = true, - description = "입출구 온도차" - ) - - val project = DpcProject( - projectFileName = "FORMULA_TEST.dpc", - formulas = listOf(f1, f2) - ) - - val json = DpcProjectManager.serializeProjectToJson(project) - assertTrue(json.contains("Power_kW")) - assertTrue(json.contains("Diff_Temp")) - - val parsed = DpcProjectManager.parseProjectFromJson(json, null) - assertEquals(2, parsed.formulas.size) - assertEquals("Power_kW", parsed.formulas[0].name) - assertEquals("\"Torque\" * \"EngineSpeed\" / 9549", parsed.formulas[0].expression) - assertEquals("kW", parsed.formulas[0].unit) - assertEquals("Diff_Temp", parsed.formulas[1].name) - assertTrue(parsed.formulas[1].isForSm) - } - - @Test - @DisplayName("Formula 수식 결과가 실시간 CSV 및 MF4 로거 파일에 _formula 접미사로 기록되어야 한다", ) - fun testFormulaLogging(@TempDir tempDir: File) { - val formulaHeaders = listOf("Power_kW" to "kW", "Diff_Temp" to "'C") - val csvName = "TEST_PROJ_2026-08-27_13-30-00_formula.csv" - val mf4Name = "TEST_PROJ_2026-08-27_13-30-00_formula.mf4" - - val telemetry = mapOf("Torque" to 250.0, "EngineSpeed" to 3600.0, "T_Inlet" to 90.0, "T_Outlet" to 75.0) - val pCalc = FormulaEvaluator.evaluate("\"Torque\" * \"EngineSpeed\" / 9549", telemetry) - val tCalc = FormulaEvaluator.evaluate("\"T_Inlet\" - \"T_Outlet\"", telemetry) - - val csvLogger = CsvMeasurementLogger(tempDir, formulaHeaders, csvName) - val csvFile = csvLogger.start() - assertNotNull(csvFile) - assertEquals(csvName, csvFile!!.name) - - csvLogger.logSample(0.1, listOf(pCalc, tCalc)) - csvLogger.stop() - - val csvContent = csvFile.readText() - assertTrue(csvContent.contains("Power_kW")) - assertTrue(csvContent.contains("Diff_Temp")) - - val mf4Logger = Mf4MeasurementLogger(tempDir, formulaHeaders, mf4Name) - val mf4File = mf4Logger.start() - assertNotNull(mf4File) - assertEquals(mf4Name, mf4File!!.name) - - mf4Logger.logSample(0.1, listOf(pCalc, tCalc)) - mf4Logger.stop() - assertTrue(mf4File.length() > 0) - } -} \ No newline at end of file diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryEngineTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryEngineTest.kt deleted file mode 100644 index b15febb..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryEngineTest.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.formula.FormulaEvaluator -import com.dct.app.core.formula.FormulaTelemetryEngine -import com.dct.app.domain.model.formula.FormulaDefinition -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class FormulaTelemetryEngineTest { - private fun formula(name: String, expression: String) = FormulaDefinition(name = name, expression = expression) - - @Test fun reverseRegistrationOrderAndAliasesUseCurrentInputs() { - val engine = FormulaTelemetryEngine() - val formulas = listOf(formula("B", "formula_A+1"), formula("A", "TORQUE*2")) - val values = mutableMapOf("Torque" to 2.0) - engine.update(formulas, values, mutableSetOf("Torque")) - assertEquals(5.0, values["B"]) - values["Torque"] = 3.0 - engine.update(formulas, values, mutableSetOf("Torque")) - assertEquals(7.0, values["B"]) - } - - @Test fun unrelatedPacketsDoNotMarkFormulaOutputsUpdated() { - val engine = FormulaTelemetryEngine() - val formulas = listOf(formula("F", "X*2"), formula("G", "F+1")) - val values = mutableMapOf("X" to 2.0) - engine.update(formulas, values, mutableSetOf("X")) - val updated = mutableSetOf("Y") - values["Y"] = 10.0 - engine.update(formulas, values, updated) - assertEquals(setOf("Y"), updated) - assertEquals(5.0, values["G"]) - values["X"] = 2.0 // Receiving the same value still represents a new real sample. - val repeated = mutableSetOf("X") - engine.update(formulas, values, repeated) - assertTrue("G" in repeated) - } - - @Test fun constantsReuseValuesButStillProvideGraphAndRecordingSamples() { - val engine = FormulaTelemetryEngine() - val formulas = listOf(formula("Dependent", "Constant+1"), formula("Constant", "42")) - val values = mutableMapOf() - engine.update(formulas, values, mutableSetOf()) - values["X"] = 2.0 - val updated = mutableSetOf("X") - engine.update(formulas, values, updated) - assertEquals(42.0, values["Constant"]) - assertTrue("Constant" in updated) - assertTrue("formula_Constant" in updated) - assertEquals(43.0, values["Dependent"]) - assertTrue("Dependent" in updated) - } - - @Test fun exactFormulaNamesTakePrecedenceAndDuplicateOutputsAreInvalid() { - val engine = FormulaTelemetryEngine() - val formulas = listOf(formula("B", "A+1"), formula("A", "X*2"), formula("a", "X*3")) - val values = mutableMapOf("X" to 2.0) - engine.update(formulas, values, mutableSetOf("X")) - assertEquals(5.0, values["B"]) - engine.update(listOf(formula("A", "2"), formula("A", "3"), formula("B", "A+1")), values, mutableSetOf()) - assertTrue(values.getValue("A").isNaN()) - assertTrue(values.getValue("B").isNaN()) - } - - @Test fun invalidInputsReplaceOldResultsAndPropagateThenRecover() { - val engine = FormulaTelemetryEngine() - val formulas = listOf(formula("A", "10/X"), formula("B", "A+1")) - val values = mutableMapOf("X" to 2.0) - engine.update(formulas, values, mutableSetOf("X")) - assertEquals(6.0, values["B"]) - for (invalid in listOf(0.0, Double.NaN, Double.POSITIVE_INFINITY)) { - values["X"] = invalid - val updated = mutableSetOf("X") - engine.update(formulas, values, updated) - assertTrue(values.getValue("A").isNaN()) - assertTrue(values.getValue("B").isNaN()) - assertTrue("formula_B" in updated) - } - values["X"] = 5.0 - engine.update(formulas, values, mutableSetOf("X")) - assertEquals(3.0, values["B"]) - } - - @Test fun missingInputIsNotZeroAndNewCaseInsensitiveKeysRecover() { - val engine = FormulaTelemetryEngine() - val formulas = listOf(formula("F", "MISSING+1")) - val values = mutableMapOf("Other" to 2.0) - engine.update(formulas, values, mutableSetOf("Other")) - assertTrue(values.getValue("F").isNaN()) - values["Missing"] = 9.0 - engine.update(formulas, values, mutableSetOf("Missing")) - assertEquals(10.0, values["F"]) - } - - @Test fun cyclesAndDescendantsAreInvalidButIndependentFormulasWork() { - val engine = FormulaTelemetryEngine() - val formulas = listOf(formula("A", "B+1"), formula("B", "A+1"), formula("C", "B+1"), formula("Self", "Self+1"), formula("Good", "X*2")) - val values = mutableMapOf("X" to 3.0) - engine.update(formulas, values, mutableSetOf("X")) - listOf("A", "B", "C", "Self").forEach { assertTrue(values.getValue(it).isNaN()) } - assertEquals(6.0, values["Good"]) - } - - @Test fun expressionEditRenameDeletionAndResetInvalidateCachedResults() { - val engine = FormulaTelemetryEngine() - val a = formula("A", "X*2") - val b = formula("B", "A+1") - val values = mutableMapOf("X" to 2.0) - engine.update(listOf(a, b), values, mutableSetOf("X")) - engine.update(listOf(a.copy(expression = "X*3"), b), values, mutableSetOf()) - assertEquals(7.0, values["B"]) - engine.update(listOf(a.copy(name = "New"), b), values, mutableSetOf()) - assertFalse("A" in values) - assertFalse("formula_A" in values) - assertTrue(values.getValue("B").isNaN()) - engine.update(listOf(b), values, mutableSetOf()) - assertFalse("New" in values) - engine.reset() - values.clear() - engine.update(listOf(a), values, mutableSetOf()) - assertTrue(values.getValue("A").isNaN()) - } - - @Test fun cachedIndexNeverEnumeratesAllSignalsOnSubsequentPackets() { - val backing = (0 until 10_000).associate { "Signal$it" to it.toDouble() }.toMutableMap() - backing["Torque"] = 20.0 - var enumerations = 0 - val values = object : MutableMap by backing { - override val keys: MutableSet - get() { enumerations++; return backing.keys } - override val entries: MutableSet> - get() = error("Per-variable full scan is forbidden") - } - val engine = FormulaTelemetryEngine() - val formulas = listOf(formula("A", "TORQUE*2"), formula("B", "NotReceived+1")) - engine.update(formulas, values, mutableSetOf("Torque")) - assertEquals(1, enumerations) - repeat(100) { - backing["Torque"] = it.toDouble() - engine.update(formulas, values, mutableSetOf("Torque")) - } - assertEquals(1, enumerations) - assertEquals(198.0, values["A"]) - } - - @Test fun linValidationIsIndependentOfTelemetryAndAllModuleNumbers() { - for (module in 1..16) { - val channel = "%02d-05".format(module) - val expression = "LIN(\"$channel\";0;0;5;100)" - assertTrue(FormulaEvaluator.validate(expression)) - assertEquals(50.0, FormulaEvaluator.evaluate(expression, mapOf("sm_${channel.replace('-', '_')}" to 2.5))) - } - assertFalse(FormulaEvaluator.validate("LIN(\"02-05\";0;0;0;100)")) - assertFalse(FormulaEvaluator.validate("LIN(\"02-05\";0;NaN;5;100)")) - } - - @Test fun linAliasesParticipateInSelectiveUpdatesAndDependencies() { - val engine = FormulaTelemetryEngine() - val formulas = listOf(formula("B", "A+1"), formula("A", "LIN(\"02-05\";0;0;5;100)")) - val values = mutableMapOf("sm_02_05" to 2.5) - engine.update(formulas, values, mutableSetOf("sm_02_05")) - assertEquals(51.0, values["B"]) - values["sm_02_05"] = 5.0 - engine.update(formulas, values, mutableSetOf("sm_02_05")) - assertEquals(101.0, values["B"]) - } - - @Test fun missingAndNonFiniteMathNeverProducePlausibleValues() { - assertTrue(FormulaEvaluator.evaluate("Unknown+1", emptyMap()).isNaN()) - assertTrue(FormulaEvaluator.evaluate("exp(10000)", emptyMap()).isNaN()) - assertTrue(FormulaEvaluator.evaluate("sqrt(-1)^0", emptyMap()).isNaN()) - } -} 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 deleted file mode 100644 index df83fbb..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt +++ /dev/null @@ -1,371 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.formula.FormulaDefinition -import com.dct.app.domain.model.mdb.MdbDatabase -import com.dct.app.domain.repository.MdbRepository -import com.dct.app.domain.model.protocol.DctMeasurementConfiguration -import com.dct.app.domain.model.project.DpcProject -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.domain.repository.PacketRepository -import com.dct.app.presentation.main.MainViewModel -import com.dct.app.presentation.main.MainUiState -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder -import io.mockk.coEvery -import io.mockk.every -import io.mockk.coVerify -import io.mockk.coVerifyOrder -import com.dct.app.core.common.DctResult -import com.dct.app.core.error.DctError - -@Suppress("UNCHECKED_CAST") -class FormulaTelemetryRegressionTest { - @TempDir lateinit var directory: File - private lateinit var fixture: MainViewModelTest - private lateinit var vm: MainViewModel - private lateinit var packets: MutableSharedFlow - private lateinit var repository: PacketRepository - private var telemetrySequence = 0L - private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } - - @BeforeEach fun setUp() { - fixture = MainViewModelTest().also { it.setUp() } - vm = field(fixture, "viewModel").get(fixture) as MainViewModel - repository = field(fixture, "packetRepository").get(fixture) as PacketRepository - packets = repository.packetStream as MutableSharedFlow - vm.setCurrentProject(DpcProject(measuredFilesFolder = directory.path, writeCsv = true, writeMf4 = false)) - (field(fixture, "connectionState").get(fixture) as MutableStateFlow).value = DeviceConnectionState.READY - } - - @AfterEach fun tearDown() = fixture.tearDown() - - private fun telemetry(value: Float, receivedAtMillis: Long = 0L) { - // Real transport assigns an ingress sequence, preserving distinct same-millisecond packets. - assertTrue(packets.tryEmit(DctPacket(0x02, ByteArray(18).also { - val buffer = ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) - buffer.position(2) - buffer.putFloat(value).putFloat(2f).putFloat(3f).putFloat(4f) - }, ingressSequence = ++telemetrySequence, receivedAtMillis = receivedAtMillis))) - } - - private fun openMdbWithoutRawConsumers() { - // MainViewModelTest's UI fixture leaves both byte offsets at zero. Use a - // valid record layout when exercising the real binary decoder (UINT16 + INT8). - val sample = field(fixture, "sampleDb").get(fixture) as MdbDatabase - val source = sample.copy(groups = sample.groups.map { group -> - group.copy(signals = group.signals.mapIndexed { index, signal -> signal.copy(arrayIndex = if (index == 0) 0 else 2) }) - }) - val mdb = field(fixture, "mdbRepository").get(fixture) as MdbRepository - every { mdb.getCurrentDatabase() } returns source - vm.openMdbFromFile(File("Sample.mdb")) - // Legacy dashboard defaults must not register inputs on behalf of Formula. - val ui = field(vm, "_uiState").get(vm) as MutableStateFlow - ui.value = ui.value.copy(dashboardTiles = emptyList()) - } - - @Test fun reverseOrderAndErrorsUseSamePacketValues() { - vm.startMeasure() - vm.addFormula(FormulaDefinition(name = "B", expression = "A+1")) - vm.addFormula(FormulaDefinition(name = "A", expression = "10/Incar_Snr")) - telemetry(2f) - assertEquals(6.0, vm.liveTelemetryValues.value["B"]) - telemetry(0f) - assertTrue(vm.liveTelemetryValues.value.getValue("A").isNaN()) - assertTrue(vm.liveTelemetryValues.value.getValue("B").isNaN()) - telemetry(5f) - assertEquals(3.0, vm.liveTelemetryValues.value["B"]) - } - - @Test fun measurementRestartClearsAllPagesAndLiveCachesThenUsesOnlyNewSamples() { - 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"))) - telemetry(2f) - vm.stopMeasure() - 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)) - val beforeCount = vm.uiState.value.packetCount - 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() } - renderers.forEach { assertTrue(it.seriesListFlow.value.single().buffer.size > 0) } - assertEquals(4.0, vm.liveTelemetryValues.value["F"]) - - vm.startMeasure() - - assertTrue(vm.liveTelemetryValues.value.isEmpty()) - assertTrue(raw.isEmpty()) - assertEquals(beforeCount, vm.uiState.value.packetCount) - assertTrue(vm.uiState.value.dashboardTiles.all { it.value == 0.0 && it.historyBuffer.size == 0 }) - assertEquals(project, vm.currentProject.value) - assertEquals(formulas, vm.formulas.value) - assertEquals(pages, vm.pages.value.map { it.id to it.title }) - renderers.forEachIndexed { index, renderer -> - assertEquals(bindings[index], renderer.exportWorkspaceState()) - assertEquals(0, renderer.seriesListFlow.value.single().buffer.size) - } - telemetry(7f) - assertEquals(14.0, vm.liveTelemetryValues.value["F"]) - renderers.forEach { - assertEquals(1, it.seriesListFlow.value.single().buffer.size) - assertEquals(14.0, it.seriesListFlow.value.single().buffer.getLatestValue()) - } - assertEquals(7.0, vm.uiState.value.dashboardTiles.single().value) - } - - @Test fun pendingAndFailedStartRetainOldValuesUntilStartSucceeds() { - vm.startMeasure() - vm.chartRenderer.bindSignal("s", "Incar_Snr", AxisPosition.LEFT_0, 0x123456L) - telemetry(2f) - vm.stopMeasure() - val before = vm.chartRenderer.seriesListFlow.value.single().buffer.size - val response = kotlinx.coroutines.CompletableDeferred>() - coEvery { repository.startStreaming(any()) } coAnswers { response.await() } - vm.startMeasure() - assertEquals(2.0, vm.liveTelemetryValues.value["Incar_Snr"]) - assertEquals(before, vm.chartRenderer.seriesListFlow.value.single().buffer.size) - response.complete(DctResult.Failure(DctError.ProtocolError.ConnectionFailed("START failed"))) - assertEquals(2.0, vm.liveTelemetryValues.value["Incar_Snr"]) - assertEquals(before, vm.chartRenderer.seriesListFlow.value.single().buffer.size) - coEvery { repository.startStreaming(any()) } returns DctResult.Success(Unit) - vm.startMeasure() - assertTrue(vm.liveTelemetryValues.value.isEmpty()) - assertEquals(0, vm.chartRenderer.seriesListFlow.value.single().buffer.size) - } - - @Test fun automaticRecoveryRetainsValuesGraphsAndRawSnapshot() { - vm.startMeasure() - vm.chartRenderer.bindSignal("s", "Incar_Snr", AxisPosition.LEFT_0, 0x123456L) - // Separate the sample from the recovery gap, which may replace a same-millisecond point. - telemetry(2f, receivedAtMillis = System.currentTimeMillis() - 100L) - val before = vm.chartRenderer.seriesListFlow.value.single().buffer.size - val raw = field(vm, "latestMf4RawValues").get(vm) as MutableMap - raw["can_1"] = 55 - val recovery = repository.streamRecovery as MutableStateFlow - recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECOVERING - recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECEIVING - assertTrue(vm.uiState.value.isMeasuring) - assertEquals(2.0, vm.liveTelemetryValues.value["Incar_Snr"]) - val buffer = vm.chartRenderer.seriesListFlow.value.single().buffer - assertEquals(before + 1, buffer.size) - assertEquals(2.0, buffer.getValueAt(0)) - assertTrue(buffer.getLatestValue()!!.isNaN()) - assertEquals(55, raw["can_1"]) - } - - @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")) - vm.chartRenderer.bindSignal("formula_F", "F", AxisPosition.LEFT_0, 0xFF0000L) - vm.startMeasurementRecording() - telemetry(2f) - val graph = vm.chartRenderer.seriesListFlow.value.single { it.signalId == "formula_F" } - assertEquals(1, graph.buffer.size) - val before = vm.uiState.value.packetCount - // One connected SM channel; it has no dependency edge to Incar_Snr. - assertTrue(packets.tryEmit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 10, 0, 0)))) - assertEquals(before + 1, vm.uiState.value.packetCount) - assertEquals(1, graph.buffer.size) - telemetry(3f) - assertEquals(2, graph.buffer.size) - vm.stopMeasurementRecording() - val csv = directory.listFiles()!!.single { it.name.endsWith("_formula.csv") } - // 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() { - vm.startMeasure() - val f = FormulaDefinition(name = "F", expression = "Incar_Snr*2") - vm.addFormula(f) - telemetry(2f) - vm.updateFormula(f.copy(expression = "Incar_Snr*3")) - assertEquals(6.0, vm.liveTelemetryValues.value["F"]) - val renamed = f.copy(name = "New") - vm.updateFormula(renamed) - assertFalse("F" in vm.liveTelemetryValues.value) - assertFalse("formula_F" in vm.liveTelemetryValues.value) - assertEquals(4.0, vm.liveTelemetryValues.value["New"]) - vm.deleteFormula(renamed) - assertFalse("New" in vm.liveTelemetryValues.value) - assertFalse("formula_New" in vm.liveTelemetryValues.value) - } - - @Test fun calculationErrorIsRecordedAsNoValueNotOldGoodValue() { - vm.startMeasure() - vm.addFormula(FormulaDefinition(name = "F", expression = "10/Incar_Snr")) - vm.startMeasurementRecording() - telemetry(2f) - telemetry(0f) - vm.stopMeasurementRecording() - val csv = directory.listFiles()!!.single { it.name.endsWith("_formula.csv") } - assertTrue(csv.readLines().last().contains("No value")) - } - - @Test fun formulaCreatedWhileIdleRecoversFromMissingValueOnFirstInput() { - vm.addFormula(FormulaDefinition(name = "F", expression = "INCAR_SNR*2")) - assertFalse(vm.uiState.value.isMeasuring) - assertTrue(vm.liveTelemetryValues.value.getValue("F").isNaN()) - vm.startMeasure() - telemetry(3f) - assertEquals(6.0, vm.liveTelemetryValues.value["F"]) - } - - @Test fun idleFormulaRegistersItsMdbInputsWithoutBindingRawSignalsToGraph() { - openMdbWithoutRawConsumers() - vm.addFormula(FormulaDefinition(name = "F", expression = "TA_MOTOR_SPEED*2")) - val configurationMethod = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration").also { it.isAccessible = true } - val configuration = configurationMethod.invoke(vm) as DctMeasurementConfiguration - assertEquals(listOf("Ta_Motor_Speed"), configuration.selectedMdbSignals.map { it.name }) - vm.startMeasure() - coVerifyOrder { - repository.configureMeasurement(match { it.selectedMdbSignals.any { signal -> signal.name == "Ta_Motor_Speed" } }) - repository.startStreaming(any()) - } - assertTrue(vm.uiState.value.isMeasuring) - val before = vm.uiState.value.packetCount - assertTrue(packets.tryEmit(DctPacket(0xA3.toByte(), byteArrayOf(1, 1, 0, 12, 0)))) - assertEquals(before + 1, vm.uiState.value.packetCount, "Live values: ${vm.liveTelemetryValues.value}") - assertEquals(3.0, vm.liveTelemetryValues.value["Ta_Motor_Speed"]) - assertEquals(6.0, vm.liveTelemetryValues.value["F"]) - } - - @Test fun formulaAddedAfterReadySynchronizationIsRegisteredBeforeStart() { - openMdbWithoutRawConsumers() - vm.syncDeviceConfiguration() - vm.addFormula(FormulaDefinition(name = "F", expression = "Ta_Motor_Speed*2")) - vm.startMeasure() - coVerify(exactly = 2) { repository.configureMeasurement(any()) } - coVerify(exactly = 1) { repository.configureMeasurement(match { it.selectedMdbSignals.map { s -> s.idx } == listOf(1) }) } - vm.stopMeasure() - vm.startMeasure() - // Unchanged selection is reused; no full configuration on every restart. - coVerify(exactly = 2) { repository.configureMeasurement(any()) } - } - - @Test fun failedFormulaInputConfigurationDoesNotStartAndCanBeRetried() { - openMdbWithoutRawConsumers() - vm.syncDeviceConfiguration() - vm.addFormula(FormulaDefinition(name = "F", expression = "Ta_Motor_Speed*2")) - coEvery { repository.configureMeasurement(any()) } returns DctResult.Failure(DctError.Unknown("configuration NACK")) - vm.startMeasure() - assertFalse(vm.uiState.value.isMeasuring) - coVerify(exactly = 0) { repository.startStreaming(any()) } - coEvery { repository.configureMeasurement(any()) } returns DctResult.Success(Unit) - vm.startMeasure() - assertTrue(vm.uiState.value.isMeasuring) - } - - @Test fun nestedFormulasAndRawIdsRegisterAllMdbInputs() { - openMdbWithoutRawConsumers() - vm.addFormula(FormulaDefinition(name = "Result", expression = "Intermediate+RAW_SIG_1_2")) - vm.addFormula(FormulaDefinition(name = "Intermediate", expression = "ta_motor_speed*2")) - val method = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration").also { it.isAccessible = true } - assertEquals(listOf(1, 2), (method.invoke(vm) as DctMeasurementConfiguration).selectedMdbSignals.map { it.idx }) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaWindowStateTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaWindowStateTest.kt deleted file mode 100644 index bcc9edb..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/FormulaWindowStateTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.dct.app.tests - -import androidx.compose.ui.unit.dp -import com.dct.app.data.repository.DpcProjectManager -import com.dct.app.domain.model.formula.FormulaDefinition -import com.dct.app.domain.model.project.* -import com.dct.app.presentation.signaltree.WindowColumnWidths -import com.dct.app.presentation.signaltree.WindowUiStore -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class FormulaWindowStateTest { - @Test fun independentFormulaColumnWidthsAndSearchRoundTripThroughDpc() { - var saved = emptyMap() - val widths = WindowColumnWidths(WindowUiStore(mapOf("search" to "Power")) { saved = it }) - val expected = mapOf("name" to 210f, "value" to 110f, "unit" to 65f, "color" to 90f, "expression" to 330f, "act" to 70f) - expected.forEach { (key, width) -> widths[key] = width.dp } - val project = DpcProject(formulas = listOf(FormulaDefinition(name = "Power", expression = "Torque*2", unit = "kW")), - workspace = DpcWorkspaceState(activePageId = "page_1", pages = listOf(DpcPageState("page_1", "Page 1", windows = listOf( - DpcWindowState("formula", "FORMULA_LIST", "Formula", contentState = saved) - ))))) - val restored = DpcProjectManager.parseProjectFromJson(DpcProjectManager.serializeProjectToJson(project), null) - val content = restored.workspace!!.pages.single().windows.single().contentState - val restoredWidths = WindowColumnWidths(WindowUiStore(content) {}) - assertEquals("Power", content["search"]) - expected.forEach { (key, width) -> assertEquals(width.dp, restoredWidths[key]) } - // PC formula_property persists fields/name/index, not the runtime UUID. - val restoredFormula = restored.formulas.single() - assertEquals(project.formulas.single().copy(id = restoredFormula.id), restoredFormula) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt deleted file mode 100644 index bd264c8..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.protocol.* -import com.dct.app.data.maintenance.* -import com.dct.app.domain.model.mdb.* -import com.dct.app.domain.model.a2l.A2lDataType -import com.dct.app.domain.model.protocol.* -import com.dct.app.domain.model.chart.MultiAxisCoordinateTransformer -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import com.dct.app.presentation.mdi.* -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class GapClosureRegressionTest { - private fun map() = CompensationMap(CompensationMapType.TYPE_A, 1, "Y", 0x345678, MdbAttrType.ATTR_3_INT16, - -300.0, 300.0, "X", 0x123456, MdbAttrType.ATTR_2_UINT16, 0.5, 1.0, - points = listOf(MapPoint(0, 0x1234, -2, 0.0), MapPoint(1, 0x5678, 3, 0.0))) - private fun response(bytes: ByteArray) = DctPacket.create(DctCommand.RES_COMPENSATION_TYPE_DATA, bytes) - - @Test fun calibrationIntegersUseBigEndianAndSignedValues() { - assertArrayEquals(byteArrayOf(0x12, 0x34), DctCalibrationProtocol.encodeInteger(4660.0, MdbAttrType.ATTR_2_UINT16)) - assertArrayEquals(byteArrayOf(-1, -2), DctCalibrationProtocol.encodeInteger(-2.0, MdbAttrType.ATTR_3_INT16)) - assertEquals(-2.0, DctCalibrationProtocol.decodeInteger(byteArrayOf(-1, -2), MdbAttrType.ATTR_3_INT16)) - assertEquals(4294967295.0, DctCalibrationProtocol.decodeInteger(byteArrayOf(-1,-1,-1,-1), MdbAttrType.ATTR_4_UINT32)) - } - @Test fun integerValuesAreNotSilentlyTruncated() { - for (value in listOf(-1.0, 256.0, 1.5, Double.NaN, Double.POSITIVE_INFINITY)) { - assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.encodeInteger(value, MdbAttrType.ATTR_0_UINT8) } - } - } - @Test fun mapGoldenReadAndWrite() { - assertArrayEquals(byteArrayOf(1,0x12,0x34,0x56,2,2), DctCalibrationProtocol.mapRequest(map(), true, false).payload) - assertArrayEquals(byteArrayOf(4,0x34,0x56,0x78,2,2,-1,-2,0,3), DctCalibrationProtocol.mapRequest(map(), false, true).payload) - } - @Test fun mapRejectsTruncatedAddressesAndEmptyPoints() { - for (m in listOf(map().copy(xAddr = 0x1000000), map().copy(xAddr = 0xFFFFFE), map().copy(points = emptyList()))) { - assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.mapRequest(m, true, true) } - } - } - @Test fun mapReadUpdatesRawAndPhysicalForTypeA() { - val read = DctCalibrationProtocol.mapResponse(map(), true, false, response(byteArrayOf(1,1,0x12,0x34,0x56,2,2,0,4,0,8))) - assertEquals(listOf(4,8), read.points.map { it.bkp }) - assertEquals(listOf(3.0,5.0), read.points.map { it.phy }) - assertEquals(map().points.map { it.data }, read.points.map { it.data }) - } - @Test fun mapReadUpdatesPhysicalOnYAxisForTypeB() { - val read = DctCalibrationProtocol.mapResponse(map().copy(type = CompensationMapType.TYPE_B), false, false, - response(byteArrayOf(1,2,0x34,0x56,0x78,2,2,-1,-2,0,4))) - assertEquals(listOf(0.0,3.0), read.points.map { it.phy }) - } - @Test fun mapRejectsNackWrongAddressWrongTypeCountAndLength() { - val good = byteArrayOf(1,1,0x12,0x34,0x56,2,2,0,4,0,8) - for (index in 0..6) { - val invalid = good.copyOf().also { it[index] = 99 } - assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.mapResponse(map(), true, false, response(invalid)) } - } - assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.mapResponse(map(), true, false, response(good.dropLast(1).toByteArray())) } - } - @Test fun mapWriteRequiresEchoedOperationAndAddress() { - val request = DctCalibrationProtocol.mapRequest(map(), true, true) - assertEquals(map(), DctCalibrationProtocol.mapResponse(map(), true, true, response(byteArrayOf(1) + request.payload.take(6).toByteArray()))) - assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.mapResponse(map(), true, true, response(byteArrayOf(1))) } - } - @Test fun setupRejectsNackAndWrongCommand() { - val request = DctProtocolRequests.mdbInfo(emptyList()) - DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_MDB_INFO, byteArrayOf(1))) - assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_MDB_INFO, byteArrayOf(2))) } - assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_ENABLE, byteArrayOf(1))) } - } - @Test fun setupSubtypeIsNotMistakenForSuccess() { - val request = DctProtocolRequests.canMode(true, false) - DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, byteArrayOf(6,1,1))) - // DCT_test compatibility: a legacy peer may omit the subtype/mode and - // return either [06, ACK] or a generic [ACK]. Both still require ACK=1. - DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, byteArrayOf(6,1))) - DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, byteArrayOf(1))) - for (payload in listOf(byteArrayOf(6,2,1), byteArrayOf(6,1,0), byteArrayOf(6,2), byteArrayOf(2), byteArrayOf(1,1))) { - assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, payload)) } - } - } - @Test fun handshakeValidatesVersionAndHardwareIdLengths() { - DctResponseValidator.validateSetup(DctProtocolRequests.version(), DctPacket.create(DctCommand.RES_VERSION, byteArrayOf(1,65))) - assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(DctProtocolRequests.version(), DctPacket.create(DctCommand.RES_VERSION, byteArrayOf(8,65))) } - assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(DctProtocolRequests.hardwareId(), DctPacket.create(DctCommand.RES_HWID, byteArrayOf(1))) } - } - @Test fun xcpHalfPrecisionGoldenFrames() { - assertArrayEquals(byteArrayOf(0x3E,0), XcpValueCodec.encode(A2lDataType.FLOAT16_IEEE, 1.5, true)) - assertArrayEquals(byteArrayOf(0,0xC0.toByte()), XcpValueCodec.encode(A2lDataType.FLOAT16_IEEE, -2.0, false)) - assertArrayEquals(byteArrayOf(0,1), XcpValueCodec.encode(A2lDataType.FLOAT16_IEEE, Math.scalb(1.0,-24), true)) - } - @Test fun xcpRejectsOverflowFractionsAndUnsafe64Bit() { - for ((type, value) in listOf(A2lDataType.UBYTE to 256.0, A2lDataType.SBYTE to 128.0, - A2lDataType.UWORD to 1.1, A2lDataType.UINT64 to 9007199254740992.0, - A2lDataType.FLOAT32_IEEE to Double.MAX_VALUE, A2lDataType.FLOAT16_IEEE to 65505.0)) { - assertThrows(IllegalArgumentException::class.java) { XcpValueCodec.encode(type, value, false) } - } - assertThrows(IllegalArgumentException::class.java) { DctProtocolRequests.xcpCharacteristicWrite(0xFFFFFFFF, A2lDataType.UWORD, 1.0, false) } - } - @Test fun didGoldenFramesAndBatchParsing() { - assertArrayEquals(byteArrayOf(0xF1.toByte(),0x87.toByte(),2,65,66), DeviceMaintenanceProtocol.writeDid(0xF187, byteArrayOf(65,66)).payload) - assertArrayEquals(byteArrayOf(65), DeviceMaintenanceProtocol.parseDid(byteArrayOf(0xF1.toByte(),0x87.toByte(),1,1,65), 0xF187)) - val all = DeviceMaintenanceProtocol.parseAllDid(byteArrayOf(1,0xF1.toByte(),0x87.toByte(),1,65,0xF1.toByte(),0x91.toByte(),1,66)) - assertEquals(setOf(0xF187,0xF191), all.keys) - } - @Test fun didRejectsNackTruncationAndDuplicateIds() { - assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.parseDid(byteArrayOf(-15,-121,2,0),0xF187) } - assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.parseAllDid(byteArrayOf(1,-15,-121,8,65)) } - assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.parseAllDid(byteArrayOf(1,-15,-121,0,-15,-121,0)) } - } - @Test fun firmwareHashUsesActualPcOpcode() { - val packet = DeviceMaintenanceProtocol.hashResponse(DeviceMaintenanceProtocol.HashInfo("fw", ByteArray(32) { it.toByte() }, "A")) - assertEquals(0x32.toByte(), packet.command) - assertArrayEquals(byteArrayOf(3,1,2,102,119,2,32), packet.payload.take(7).toByteArray()) - assertArrayEquals(byteArrayOf(3,2), DeviceMaintenanceProtocol.hashResponse(null).payload) - } - @Test fun firmwareAddressBanksGoldenFrameAndOverflow() { - val packet = DeviceMaintenanceProtocol.firmwareAddresses(listOf(DeviceMaintenanceProtocol.MemoryRegion(0x12345678,0x1000)), emptyList()) - assertArrayEquals(byteArrayOf(1,0x12,0x34,0x56,0x78,0,0,0x10,0,0), packet.payload) - assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.firmwareAddresses(listOf(DeviceMaintenanceProtocol.MemoryRegion(0xFFFFFFFF,2)), emptyList()) } - } - @Test fun maintenanceRequestBoundsAndAsciiValidation() { - assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.databaseName("한글") } - assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.databaseItem(256) } - assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.setSavedInterval(true,3) } - assertArrayEquals(byteArrayOf(0,1), DeviceMaintenanceProtocol.carTypeControl(1,0).payload) - } - @Test fun minimizeMaximizePreserveOriginalBounds() { - val vm = MdiViewModel(TestDispatcherProvider(UnconfinedTestDispatcher())) - val id = vm.addWindow(MdiWindowType.GRAPH, offsetX = 123f, offsetY = 45f, width = 500f)!! - val original = vm.uiState.value.windows.single() - vm.toggleMinimized(id) - assertTrue(vm.uiState.value.windows.single().isMinimized) - vm.toggleMaximized(id) - assertTrue(vm.uiState.value.windows.single().isMaximized) - assertFalse(vm.uiState.value.windows.single().isMinimized) - vm.toggleMaximized(id) - assertEquals(original.width, vm.uiState.value.windows.single().width) - assertEquals(original.offsetX, vm.uiState.value.windows.single().offsetX) - } - @Test fun restoredWindowsCannotBeStrandedWithoutSizeStateButtons() { - val vm = MdiViewModel(TestDispatcherProvider(UnconfinedTestDispatcher())) - vm.addWindow(MdiWindowType.GRAPH, offsetX = 123f, offsetY = 45f, width = 500f, height = 300f) - val normal = vm.uiState.value.windows.single() - for (saved in listOf(normal.copy(isMinimized = true), normal.copy(isMaximized = true))) { - vm.setWindows(listOf(saved), normal.id) - assertEquals(normal, vm.uiState.value.windows.single()) - assertEquals(normal.id, vm.uiState.value.activeWindowId) - } - } - @Test fun rightAxesReserveSeparateSpaceFromLegend() { - val without = MultiAxisCoordinateTransformer.calculatePlotBounds(1000f,600f,1,hasLegend=false) - val with = MultiAxisCoordinateTransformer.calculatePlotBounds(1000f,600f,1,hasLegend=false,rightAxisCount=4) - assertEquals( - without.right - MultiAxisCoordinateTransformer.DEFAULT_AXIS_WIDTH_PX * 4f, - with.right - ) - } - @Test fun pinchZoomClampsAndPersistsManualSpan() { - val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) - renderer.setTimeWindow(30.0) - renderer.zoomTimeWindow(2f,0.5) - assertEquals(15.0, renderer.exportWorkspaceState().timeWindowSec) - assertFalse(renderer.exportWorkspaceState().isXAxisAuto) - renderer.zoomTimeWindow(Float.NaN,0.5) - assertEquals(15.0, renderer.exportWorkspaceState().timeWindowSec) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt deleted file mode 100644 index ed554a5..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.* -import com.dct.app.domain.model.project.* -import com.dct.app.data.repository.DpcWorkspaceJson -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import java.time.ZoneOffset - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class GraphSettingsRegressionTest { - private fun renderer() = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { - bindSignal("a", "A", AxisPosition.LEFT_0, 0xFFL) - bindSignal("b", "B", AxisPosition.LEFT_1, 0xFF0000L) - addSampleSec("a", 1000.0, 1.0) - addSampleSec("a", 1001.0, 2.0) - addSampleSec("b", 1000.0, 500.0) - addSampleSec("b", 1001.0, 600.0) - setTimeWindow(TimeWindowPreset.AUTO) - } - - @Test fun hideKeepsMenuMetadataAndCanBeReversedEvenWhenEverythingIsHidden() { - val r = renderer() - r.setSeriesVisible("a", false) - r.setSeriesVisible("b", false) - val hidden = r.buildSnapshot(600).seriesList - assertEquals(listOf("a", "b"), hidden.map { it.signalId }) - assertTrue(hidden.all { !it.isVisible && it.points.isEmpty() }) - assertNull(hidden[0].minVisibleValue) - r.addSampleSec("a", 1002.0, 3.0) - r.setSeriesVisible("a", true) - assertTrue(r.buildSnapshot(600).seriesList.first().points.isNotEmpty()) - assertEquals(3.0, r.buildSnapshot(600).seriesList.first().latestValue) - } - - @Test fun manualAxisDoesNotDisableOtherAutoAxesAndCanReturnToAuto() { - val r = renderer() - r.setAxisRange(AxisPosition.LEFT_0, -5.0, 5.0, false) - r.addSampleSec("b", 1002.0, 900.0) - val mixed = r.buildSnapshot(600) - assertEquals(5.0, mixed.axesConfig.getValue(AxisPosition.LEFT_0).maxVal) - assertTrue(mixed.axesConfig.getValue(AxisPosition.LEFT_1).maxVal > 900.0) - assertFalse(mixed.isYAxisAuto) - r.setAxisRange(AxisPosition.LEFT_0, -5.0, 5.0, true) - r.addSampleSec("a", 1003.0, 20.0) - assertTrue(r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0).maxVal > 20.0) - assertTrue(r.buildSnapshot(600).isYAxisAuto) - r.setYAxisAuto(false) - r.setAxisAuto(AxisPosition.LEFT_0, true) - assertTrue(r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0).maxVal > 20.0) - assertFalse(r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_1).isAuto) - } - - @Test fun yAxisScaleIsIndependentAndFingerDirectionMatchesRangeSemantics() { - val r = renderer() - val autoBefore = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) - r.scaleYAxis(AxisPosition.LEFT_0, 2.0) - val autoAfter = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) - assertEquals(autoBefore.span * 2.0, autoAfter.span, 1e-9) - assertFalse(autoAfter.isAuto) - - r.setAxisRange(AxisPosition.LEFT_0, 0.0, 100.0, false) - r.setAxisRange(AxisPosition.LEFT_1, -10.0, 10.0, false) - - // Upward swipe is represented by a factor > 1 and expands only L1. - r.scaleYAxis(AxisPosition.LEFT_1, 2.0) - val expanded = r.buildSnapshot(600) - assertEquals(-20.0, expanded.axesConfig.getValue(AxisPosition.LEFT_1).minVal, 1e-9) - assertEquals(20.0, expanded.axesConfig.getValue(AxisPosition.LEFT_1).maxVal, 1e-9) - assertEquals(0.0, expanded.axesConfig.getValue(AxisPosition.LEFT_0).minVal, 1e-9) - assertEquals(100.0, expanded.axesConfig.getValue(AxisPosition.LEFT_0).maxVal, 1e-9) - assertFalse(expanded.axesConfig.getValue(AxisPosition.LEFT_1).isAuto) - - // Downward swipe is represented by a factor < 1 and narrows L1. - r.scaleYAxis(AxisPosition.LEFT_1, 0.5) - val narrowed = r.buildSnapshot(600) - assertEquals(-10.0, narrowed.axesConfig.getValue(AxisPosition.LEFT_1).minVal, 1e-9) - assertEquals(10.0, narrowed.axesConfig.getValue(AxisPosition.LEFT_1).maxVal, 1e-9) - } - - @Test fun yAxisPanMovesOnlyTheSelectedAxisAndDoubleTapResetRestoresManualBaseline() { - val r = renderer() - r.setAxisRange(AxisPosition.LEFT_0, 0.0, 100.0, false) - r.setAxisRange(AxisPosition.LEFT_1, -10.0, 10.0, false) - - // A downward drag equal to 25% of the plot height translates the - // selected axis by one quarter of its current 20-unit span. - r.panYAxis(AxisPosition.LEFT_1, 0.25) - val moved = r.buildSnapshot(600) - assertEquals(-5.0, moved.axesConfig.getValue(AxisPosition.LEFT_1).minVal, 1e-9) - assertEquals(15.0, moved.axesConfig.getValue(AxisPosition.LEFT_1).maxVal, 1e-9) - assertEquals(0.0, moved.axesConfig.getValue(AxisPosition.LEFT_0).minVal, 1e-9) - assertEquals(100.0, moved.axesConfig.getValue(AxisPosition.LEFT_0).maxVal, 1e-9) - assertFalse(moved.axesConfig.getValue(AxisPosition.LEFT_1).isAuto) - - // Axis double-tap is represented by resetYAxis in the renderer layer. - r.resetYAxis(AxisPosition.LEFT_1) - val restored = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_1) - assertEquals(-10.0, restored.minVal, 1e-9) - assertEquals(10.0, restored.maxVal, 1e-9) - assertFalse(restored.isAuto) - - // A second reset has no stale baseline and must not change the range. - r.resetYAxis(AxisPosition.LEFT_1) - val unchanged = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_1) - assertEquals(restored, unchanged) - } - - @Test fun yAxisPinchZoomUsesCentroidAnchorAndResetReturnsAutoMode() { - val r = renderer() - val before = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) - val anchor = 0.25 - - // Pinch-out factor 2 narrows the visible span by half around the - // quarter-height value (the same anchor convention used by Canvas). - r.zoomYAxis(AxisPosition.LEFT_0, factor = 2.0, anchorFraction = anchor) - val zoomed = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) - val anchorValue = before.minVal + before.span * anchor - assertEquals(before.span / 2.0, zoomed.span, 1e-9) - assertEquals(anchorValue, zoomed.minVal + zoomed.span * anchor, 1e-9) - assertFalse(zoomed.isAuto) - - r.resetYAxis(AxisPosition.LEFT_0) - val restored = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) - assertTrue(restored.isAuto) - assertEquals(before.minVal, restored.minVal, 1e-9) - assertEquals(before.maxVal, restored.maxVal, 1e-9) - } - - @Test fun hiddenSeriesDoesNotAffectSharedAutoRange() { - val r = renderer() - r.assignSeriesAxis("b", AxisPosition.LEFT_0) - r.setSeriesVisible("b", false) - assertTrue(r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0).maxVal < 10.0) - } - - @Test fun legendNoneAndHiddenSignalsAndMixedAxesSurviveProjectRoundTrip() { - val r = renderer() - r.setLegendPosition(3) - r.setSeriesVisible("b", false) - r.setAxisRange(AxisPosition.LEFT_0, .0123, .0987, false) - val workspace = DpcWorkspaceState("p", listOf(DpcPageState("p", "Page", windows = listOf( - DpcWindowState("w", "MULTI_AXIS_GRAPH", "Graph", chart = r.exportWorkspaceState(), - signals = r.seriesListFlow.value.map(DpcSignalBindingState::fromSeries)) - )))) - val window = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!!.pages.single().windows.single() - val restored = renderer() - restored.restoreWorkspaceState(window.signals.map { it.toSeries() }, window.chart) - assertEquals(3, restored.buildSnapshot(600).legendPosition) - assertFalse(restored.buildSnapshot(600).seriesList.last().isVisible) - assertEquals(.0123, restored.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0).minVal) - assertTrue(restored.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_1).isAuto) - } - - @Test fun allFiveThicknessesAreDistinctPhysicalPixels() { - assertEquals(listOf(1f, 2f, 3f, 4f, 5f), (1..5).map { chartLineWidthPx(it.toFloat()) }) - assertEquals(1f, chartLineWidthPx(Float.NaN)) - } - - @Test fun rawOutOfRangeVerticesAreNotClampedAndPulseHoldsPreviousValue() { - val input = listOf(0.0 to 150.0, 1.0 to 200.0) - val linear = mutableListOf>() - forEachChartVertex(input, InterpolationMode.LINEAR) { x, y, move -> linear.add(Triple(x,y,move)) } - assertEquals(listOf(Triple(0.0,150.0,true), Triple(1.0,200.0,false)), linear) - val pulse = mutableListOf>() - forEachChartVertex(input, InterpolationMode.PWM_STEP) { x,y,move -> pulse.add(Triple(x,y,move)) } - assertEquals(listOf(Triple(0.0,150.0,true), Triple(1.0,150.0,false), Triple(1.0,200.0,false)), pulse) - } - - @Test fun nonFiniteSamplesBreakPathsAndNeverPolluteAutoBounds() { - val commands = mutableListOf() - forEachChartVertex(listOf(0.0 to 1.0, 1.0 to Double.NaN, 2.0 to 3.0), InterpolationMode.LINEAR) { _,_,move -> commands.add(move) } - assertEquals(listOf(true, true), commands) - val r = renderer() - r.addSampleSec("a", 1002.0, Double.POSITIVE_INFINITY) - val axis = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) - assertTrue(isValidChartAxisRange(axis.minVal, axis.maxVal)) - } - - @Test fun axisEditingRetainsPrecisionAndRejectsInvalidOrOverflowingBounds() { - for (value in listOf(.0123456789, -.00000012345, 123456.7890123)) { - assertEquals(value, chartAxisInputText(value).toDouble()) - } - for ((min,max) in listOf(Double.NaN to 1.0, 0.0 to Double.POSITIVE_INFINITY, - 1.0 to 1.0, 2.0 to 1.0, -Double.MAX_VALUE to Double.MAX_VALUE)) { - assertFalse(isValidChartAxisRange(min,max)) - val r = renderer() - val before = r.exportWorkspaceState() - r.setAxisRange(AxisPosition.LEFT_0,min,max,false) - r.setYAxisRange(min,max) - r.setAxisMinMax(AxisPosition.LEFT_0,min,max) - assertEquals(before, r.exportWorkspaceState()) - } - } - - @Test fun xAxisManualTimeUsesReadableLocalTextAndParsesLegacySeconds() { - val value = 1_789_447_222.234 - val text = chartTimeInputText(value, ZoneOffset.UTC) - assertEquals("2026-09-15 04:40:22.234", text) - assertFalse(text.contains('E', ignoreCase = true)) - assertEquals(value, parseChartTimeInput(text, value, ZoneOffset.UTC)!!, 0.001) - assertEquals(value, parseChartTimeInput("04:40:22.234", value, ZoneOffset.UTC)!!, 0.001) - assertEquals(value, parseChartTimeInput(value.toString(), value, ZoneOffset.UTC)!!, 0.001) - } - - @Test fun zoomSwitchesFromFixedPresetToAdaptiveTicksEvenWhileLive() { - val r = renderer() - r.setTimeWindow(TimeWindowPreset.MIN_1) - r.zoomTimeWindow(1000f, .4) - val s = r.buildSnapshot(600) - assertEquals(TimeWindowPreset.MANUAL, s.xAxisPreset) - val ticks = ChartAxisTicks.time(s.timeMinSec, s.timeMaxSec, 600f, s.xAxisPreset) - assertTrue(ticks.values.size > 1) - assertTrue(ticks.interval.seconds < 1.0) - } - - @Test fun smallYAxisTicksKeepDistinctDecimalValues() { - val ticks = listOf(.02, .04, .06, .08) - assertEquals(listOf("0.02", "0.04", "0.06", "0.08"), ticks.map { chartAxisTickText(it, ticks) }) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/GraphTypeTest.kt b/Android/app/src/test/java/com/dct/app/tests/GraphTypeTest.kt deleted file mode 100644 index 2890d84..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/GraphTypeTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.repository.DpcProjectManager -import com.dct.app.domain.model.chart.* -import com.dct.app.domain.model.project.DpcProject -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.json.JSONObject -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class GraphTypeTest { - @Test fun globalModeChangesPausedSnapshotWithoutLosingDataOrStyle() { - val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) - renderer.bindSignal("rpm", "RPM", AxisPosition.RIGHT_1, 0xFFAABBCC, "rpm") - renderer.setAxisRange(AxisPosition.RIGHT_1, 0.0, 100.0, false) - renderer.setSeriesThickness("rpm", 4f) - renderer.setSeriesDashStyle("rpm", 2) - renderer.addSampleSec("rpm", 1.0, 10.0) - renderer.addSampleSec("rpm", 2.0, 20.0) - renderer.pause() - val original = renderer.seriesListFlow.value.single() - val axes = renderer.exportWorkspaceState().axes - - renderer.setGraphInterpolationMode(InterpolationMode.PWM_STEP) - assertEquals(original.copy(interpolationMode = InterpolationMode.PWM_STEP), renderer.seriesListFlow.value.single()) - val pulse = renderer.renderSnapshotFlow.value.seriesList.single() - assertEquals(InterpolationMode.PWM_STEP, pulse.interpolationMode) - assertEquals(20.0, pulse.latestValue) - assertSame(original.buffer, renderer.seriesListFlow.value.single().buffer) - assertEquals(axes, renderer.exportWorkspaceState().axes) - - renderer.setGraphInterpolationMode(InterpolationMode.LINEAR) - assertEquals(original, renderer.seriesListFlow.value.single()) - assertEquals(InterpolationMode.LINEAR, renderer.renderSnapshotFlow.value.seriesList.single().interpolationMode) - } - - @Test fun newAndRestoredSignalsInheritCommonMode() { - val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) - renderer.setGraphInterpolationMode(InterpolationMode.PWM_STEP) - renderer.bindSignal("new", "New", AxisPosition.LEFT_0, 0xFF123456) - assertEquals(InterpolationMode.PWM_STEP, renderer.seriesListFlow.value.single().interpolationMode) - val savedLinear = renderer.seriesListFlow.value.single().copy(interpolationMode = InterpolationMode.LINEAR) - renderer.restoreWorkspaceState(listOf(savedLinear), null) - assertEquals(InterpolationMode.PWM_STEP, renderer.seriesListFlow.value.single().interpolationMode) - } - - @Test fun pulseAndLinearCursorValuesMatchTheirPaths() { - val points = listOf(0.0 to 0.0, 1.0 to 10.0, 2.0 to 0.0) - assertEquals(5.0, interpolateChartValue(points, 0.5, InterpolationMode.LINEAR)) - assertEquals(0.0, interpolateChartValue(points, 0.5, InterpolationMode.PWM_STEP)) - assertEquals(10.0, interpolateChartValue(points, 1.0, InterpolationMode.PWM_STEP)) - assertEquals(10.0, interpolateChartValue(points, 1.5, InterpolationMode.PWM_STEP)) - assertEquals(0.0, interpolateChartValue(points, 2.0, InterpolationMode.PWM_STEP)) - assertEquals(0.0, interpolateChartValue(points, -1.0, InterpolationMode.LINEAR)) - assertNull(interpolateChartValue(emptyList(), 0.0, InterpolationMode.LINEAR)) - assertNull(interpolateChartValue(points, Double.NaN, InterpolationMode.PWM_STEP)) - } - - @Test fun pcGraphPropertyRoundTripsAndPreservesOtherKeys() { - val raw = JSONObject("""{"graphItem_property":{"graph_linear":true,"vendor_setting":123}}""") - val project = DpcProject(rawJsonRoot = raw, graphLinear = false) - val saved = DpcProjectManager.serializeProjectToJson(project) - val property = JSONObject(saved).getJSONObject("graphItem_property") - assertFalse(property.getBoolean("graph_linear")) - assertEquals(123, property.getInt("vendor_setting")) - val restored = DpcProjectManager.parseProjectFromJson(saved, null) - assertFalse(restored.graphLinear) - val linear = DpcProjectManager.serializeProjectToJson(restored.copy(graphLinear = true)) - assertTrue(DpcProjectManager.parseProjectFromJson(linear, null).graphLinear) - } - - @Test fun importsPcPulseAndDefaultsOlderProjectsToLinear() { - assertFalse(DpcProjectManager.parseProjectFromJson("""{"graphItem_property":{"graph_linear":false}}""", null).graphLinear) - assertTrue(DpcProjectManager.parseProjectFromJson("{}", null).graphLinear) - assertTrue(DpcProjectManager.parseProjectFromJson("""{"graphItem_property":{}}""", null).graphLinear) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/HardwareWireRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/HardwareWireRegressionTest.kt deleted file mode 100644 index 85789a5..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/HardwareWireRegressionTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.protocol.* -import com.dct.app.domain.model.protocol.* -import com.dct.app.domain.model.mdb.* -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.assertThrows - -class HardwareWireRegressionTest { - private fun hex(s: String) = s.split(" ").map { it.toInt(16).toByte() }.toByteArray() - - @Test fun capturedHardwareResponses() { - val codec = DctPacketCodec() - // 20260716_104112_log.txt: untouched complete hardware frames. - val mode = codec.decode(hex("A1 00 04 89 06 01 00 CA 7E"))!! - DctResponseValidator.validateSetup(DctProtocolRequests.canMode(false, false), mode) - val start = codec.decode(hex("A1 00 03 A0 01 01 B9 7E"))!! - DctResponseValidator.validateSetup(DctProtocolRequests.startAll(), start) - } - - @Test fun startStatusPrecedesTypeAndMalformedRepliesFail() { - for (payload in listOf("02 01", "01 02", "01 00", "01", "01 01 00", "")) { - val bytes = if (payload.isEmpty()) byteArrayOf() else hex(payload) - assertThrows(IllegalArgumentException::class.java) { - DctResponseValidator.validateSetup(DctProtocolRequests.startAll(), - DctPacket.create(DctCommand.RES_START_TRANSMIT_DATA, bytes)) - } - } - } - - @Test fun allCanModeFlagsMustBeEchoed() { - for (modern in listOf(false, true)) for (old in listOf(false, true)) { - val request = DctProtocolRequests.canMode(modern, old) - DctResponseValidator.validateSetup(request, - DctPacket.create(DctCommand.RES_DCT_ENV, byteArrayOf(6, 1, request.payload[1]))) - } - } - - @Test fun wireCanIdIsIndependentOfDbcSignalByteOrder() { - val parsed = DctStreamPayloadParser.parseCanStream(hex("02 01 01 01 00 10 02 12 34")) - as DctStreamPayloadParser.CanStreamPayload.Can - assertThat(parsed.frames.single().canId).isEqualTo(0x10L) - assertThat(parsed.frames.single().data).isEqualTo(hex("12 34")) - } - - // Golden bytes follow PC MAKEWORD(low, high) / MAKELONG(low, high), not the encoder. - @Test fun mdbBigEndianSignedUnsignedBoundariesAndScaling() { - val vectors = listOf( - Triple(MdbAttrType.ATTR_2_UINT16, "00 64", 100.0), - Triple(MdbAttrType.ATTR_2_UINT16, "12 34", 4660.0), - Triple(MdbAttrType.ATTR_2_UINT16, "FF FF", 65535.0), - Triple(MdbAttrType.ATTR_3_INT16, "FF 9C", -100.0), - Triple(MdbAttrType.ATTR_3_INT16, "80 00", -32768.0), - Triple(MdbAttrType.ATTR_4_UINT32, "12 34 56 78", 305419896.0), - Triple(MdbAttrType.ATTR_4_UINT32, "FF FF FF FF", 4294967295.0), - Triple(MdbAttrType.ATTR_5_INT32, "80 00 00 00", -2147483648.0), - Triple(MdbAttrType.ATTR_5_INT32, "7F FF FF FF", 2147483647.0), - Triple(MdbAttrType.ATTR_5_INT32, "FF FF FF 9C", -100.0) - ) - for ((attr, bytes, raw) in vectors) { - val definition = SignalDefinition(7, 1, "Value", attr, 0.5, -2.0, "", 0, - -Double.MAX_VALUE, Double.MAX_VALUE, arrayIndex = 0) - val result = MdbRecordDecoder.decode(7, hex(bytes), listOf(definition)) - as MdbRecordDecoder.DecodeResult.Success - assertThat(result.signals.single().rawValue.toDouble()).isEqualTo(raw) - assertThat(result.signals.single().physicalValue).isEqualTo(raw * 0.5 - 2.0) - } - } -} 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 deleted file mode 100644 index 55bb43e..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/HeartbeatIdleRegressionTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -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/JackcessLoadUnitTest.kt b/Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt deleted file mode 100644 index aad5dd6..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.dct.app.tests - -import com.google.common.truth.Truth.assertThat -import com.healthmarketscience.jackcess.ColumnBuilder -import com.healthmarketscience.jackcess.CursorBuilder -import com.healthmarketscience.jackcess.DataType -import com.healthmarketscience.jackcess.Database -import com.healthmarketscience.jackcess.DatabaseBuilder -import com.healthmarketscience.jackcess.TableBuilder -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.charset.Charset - -class JackcessLoadUnitTest { - - @Test - @DisplayName("T1-MDB-01: Jackcess 4.0.11 MDB 생성, VarNif/VarLst 테이블 스키마 정의 및 데이터 CRUD 검증") - fun testJackcessMdbOperations(@TempDir tempDir: File) { - val mdbFile = File(tempDir, "test_dct_calibration.mdb") - - // 1. MDB 파일 생성 (V2000 포맷) - val db: Database = DatabaseBuilder.create(Database.FileFormat.V2000, mdbFile) - - assertThat(db).isNotNull() - - // 2. VarNif (신호 그룹 메타데이터) 테이블 생성 - val varNifTable = TableBuilder("VarNif") - .addColumn(ColumnBuilder("RECLID", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("IDX", DataType.TEXT).setLength(50).toColumn()) - .addColumn(ColumnBuilder("DESCRIP", DataType.TEXT).setLength(255).toColumn()) - .addColumn(ColumnBuilder("USAGE", DataType.BOOLEAN).toColumn()) - .toTable(db) - - // 3. VarLst (개별 신호 정의) 테이블 생성 - val varLstTable = TableBuilder("VarLst") - .addColumn(ColumnBuilder("RECLID", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("NAME", DataType.TEXT).setLength(255).toColumn()) - .addColumn(ColumnBuilder("ATTR", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("CVS_A", DataType.DOUBLE).toColumn()) - .addColumn(ColumnBuilder("CVS_B", DataType.DOUBLE).toColumn()) - .addColumn(ColumnBuilder("UNIT", DataType.TEXT).setLength(50).toColumn()) - .toTable(db) - - // 4. 데이터 추가 - varNifTable.addRow(1L, "NIF_01", "엔진_센서_그룹", true) - varLstTable.addRow(1L, 0L, "EngineRPM", 4L, 1.0, 0.0, "rpm") - varLstTable.addRow(1L, 1L, "CoolantTemp", 2L, 0.1, -40.0, "°C") - - db.flush() - db.close() - - // 5. MDB 파일 재오픈 및 데이터 검증 - val reOpenedDb = DatabaseBuilder.open(mdbFile) - - val readVarNif = reOpenedDb.getTable("VarNif") - val readVarLst = reOpenedDb.getTable("VarLst") - - assertThat(readVarNif.rowCount).isEqualTo(1) - assertThat(readVarLst.rowCount).isEqualTo(2) - - val firstNifRow = readVarNif.iterator().next() - assertThat((firstNifRow["RECLID"] as Number).toLong()).isEqualTo(1L) - assertThat(firstNifRow["DESCRIP"]).isEqualTo("엔진_센서_그룹") - - // 6. 데이터 수정 (CursorBuilder 활용) - val cursor = CursorBuilder.createCursor(readVarLst) - var updated = false - while (cursor.moveToNextRow()) { - val row = cursor.currentRow - if (row["NAME"] == "EngineRPM") { - row["UNIT"] = "RPM" - cursor.updateCurrentRowFromMap(row) - updated = true - break - } - } - assertThat(updated).isTrue() - - reOpenedDb.flush() - reOpenedDb.close() - - // 7. 수정 결과 최종 확인 - val verifiedDb = DatabaseBuilder.open(mdbFile) - - val verifiedVarLst = verifiedDb.getTable("VarLst") - val rpmRow = verifiedVarLst.iterator().asSequence().first { it["NAME"] == "EngineRPM" } - assertThat(rpmRow["UNIT"]).isEqualTo("RPM") - - verifiedDb.close() - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt b/Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt deleted file mode 100644 index dd8b5cd..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.logger.Mf4MeasurementLogger -import com.dct.app.data.logger.PcMf4Channel -import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.zip.Inflater - -/** Accelerated component checks; these do not simulate radio, Doze, heat or 24 hours of wall time. */ -class LongRunMeasurementReviewTest { - @TempDir lateinit var directory: File - private val samples = 24 * 60 * 60 * 30 - - @Test fun twentyFourHoursAtThirtyHertzKeepsRingCapacityAndNewestSamples() { - val ring = TimeSeriesRingBuffer() - repeat(samples) { index -> ring.addSample(index / 30.0, index.toDouble()) } - assertEquals(100_000, ring.capacity) - assertEquals(ring.capacity, ring.size) - assertEquals((samples - ring.capacity) / 30.0, ring.getOldestTimestamp()!!, 1e-6) - assertEquals((samples - 1) / 30.0, ring.getLatestTimestamp()!!, 1e-6) - assertEquals((samples - 1).toDouble(), ring.getLatestValue()) - assertEquals((samples - ring.capacity).toDouble(), ring.getOldestValue()) - } - - @Test fun compressedMf4RetainsCountTimesAndValuesAcrossTwentyFourLogicalHours() { - val channel = PcMf4Channel("Counter", dataType = 0, bitCount = 16) - val logger = Mf4MeasurementLogger(directory, listOf("Counter" to ""), "24h.mf4", listOf(channel)) - // 23:59:59 KST: the simulated stream crosses midnight almost immediately. - val origin = java.time.Instant.parse("2026-10-28T14:59:59Z").toEpochMilli() - val file = logger.start(origin)!! - try { - repeat(samples) { index -> - logger.logRawSampleAtMillis(origin + index * 1000L / 30L, listOf(index and 0xFFFF)) - } - } finally { logger.stop() } - val bytes = file.readBytes() - val data = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) - assertEquals(samples.toLong(), data.getLong(232 + 80)) - assertEquals(10, data.getInt(232 + 96)) // Time float64 + original uint16. - assertEquals(0, data.getShort(60).toInt()) // Finalized only after links/counts are patched. - val hl = data.getLong(168 + 40).toInt() - assertEquals("##HL", String(bytes, hl, 4)) - var dl = data.getLong(hl + 24).toInt() - var total = 0 - var chunks = 0 - var previousTime = -1.0 - while (dl != 0) { - assertTrue(++chunks < 1000, "Invalid/cyclic DL chain") - val dz = data.getLong(dl + 32).toInt() - assertEquals("##DZ", String(bytes, dz, 4)) - val rawSize = data.getLong(dz + 32).toInt() - val zipSize = data.getLong(dz + 40).toInt() - val transposed = ByteArray(rawSize) - val inflater = Inflater() - try { - inflater.setInput(bytes, dz + 48, zipSize) - var count = 0 - while (!inflater.finished() && count < rawSize) { - val n = inflater.inflate(transposed, count, rawSize - count) - assertTrue(n > 0, "Truncated compressed chunk") - count += n - } - assertTrue(inflater.finished()) - assertEquals(rawSize, count) - } finally { inflater.end() } - val rows = rawSize / 10 - for (row in listOf(0, rows - 1).distinct()) { - val timeBytes = ByteArray(8) { column -> transposed[column * rows + row] } - val time = ByteBuffer.wrap(timeBytes).order(ByteOrder.LITTLE_ENDIAN).double - assertEquals(((total + row) * 1000L / 30L) / 1000.0, time, 1e-6) - assertTrue(time >= previousTime) - previousTime = time - val raw = (transposed[8 * rows + row].toInt() and 255) or - ((transposed[9 * rows + row].toInt() and 255) shl 8) - assertEquals((total + row) and 0xFFFF, raw) - } - total += rows - dl = data.getLong(dl + 24).toInt() - } - assertEquals(samples, total) - assertEquals(260, chunks) - assertEquals(86_399.966, previousTime, 1e-6) - } -} 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 deleted file mode 100644 index 89af31f..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt +++ /dev/null @@ -1,2018 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.DctResult -import com.dct.app.core.common.EventBus -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.chart.InterpolationMode -import com.dct.app.domain.model.mdb.* -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.domain.repository.MdbRepository -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.domain.repository.PacketRepository -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import com.dct.app.presentation.calibration.CalibrationEditorMode -import com.dct.app.presentation.calibration.CalibrationViewModel -import com.dct.app.presentation.main.MainViewModel -import com.dct.app.presentation.main.ProjectLoadState -import com.dct.app.presentation.mdi.MdiViewModel -import com.dct.app.presentation.mdi.MdiWindowType -import com.dct.app.presentation.signaltree.AddToChartRequest -import com.dct.app.presentation.signaltree.SignalTreeNode -import com.dct.app.presentation.signaltree.SignalTreeNodeType -import com.dct.app.presentation.signaltree.SignalTreeViewModel -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import java.io.File - -@OptIn(ExperimentalCoroutinesApi::class) -@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")) - val window = viewModel.addGraphWindow() - val renderer = viewModel.getChartRenderer(window) - renderer.bindSignal("sig_1_1", "Ta_Motor_Speed", AxisPosition.LEFT_0, 0x112233L) - renderer.bindSignal("sig_1_2", "Ta_Inverter_Temp", AxisPosition.LEFT_1, 0x445566L) - renderer.bindSignal("can_7", "CAN", AxisPosition.LEFT_2, 0x778899L) - renderer.addSampleSec("sig_1_1", 1.0, 10.0) - renderer.addSampleSec("can_7", 1.0, 20.0) - val oldSignals = sampleDb.allSignals - val changed = sampleDb.copy(groups = sampleDb.groups.map { group -> - group.copy(signals = group.signals.map { signal -> - if (signal.idx == 2) signal.copy(name = "Replacement") else signal - }) - }) - every { mdbRepository.getCurrentDatabase() } returns changed - viewModel.openMdbFromFile(File("Replacement.mdb")) - val reconcile = MainViewModel::class.java.getDeclaredMethod( - "reconcilePropertySourceBindings", Boolean::class.javaPrimitiveType, - Boolean::class.javaPrimitiveType, Boolean::class.javaPrimitiveType, - List::class.java, List::class.java - ).apply { isAccessible = true } - reconcile.invoke(viewModel, true, false, false, oldSignals, emptyList()) - - val series = renderer.seriesListFlow.value.associateBy { it.signalId } - assertTrue("sig_1_1" in series) - assertFalse("sig_1_2" in series) - assertEquals(0, series.getValue("sig_1_1").buffer.size) - assertEquals(1, series.getValue("can_7").buffer.size) - } - - @Test fun seatToHe1iPropertyChangeKeepsNamedItemsAcrossWindowsPagesAndDpc() { - val intake = sampleDb.allSignals[0].copy(reclid = 160, idx = 12, name = "Intake", - cvsA = 0.01953125, unit = "C", color = 11500L) - val speed = sampleDb.allSignals[1].copy(reclid = 160, idx = 25, name = "Speed", - unit = "KPH", color = 39500L, max = 260.0) - val removed = intake.copy(idx = 30, name = "SeatOnly") - val oldSignals = listOf(intake, speed, removed) - sampleDb = sampleDb.copy(groups = listOf(sampleDb.groups.single().copy(signals = oldSignals))) - every { mdbRepository.getCurrentDatabase() } returns sampleDb - viewModel.openMdbFromFile(File("Seat.mdb")) - viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(mdbFileName = "Seat.mdb")) - val intakeBinding = com.dct.app.domain.model.project.DpcSignalBindingState( - "sig_160_12", "Intake", AxisPosition.LEFT_1, intake.color, "C", 4f, 3, false) - val speedBinding = intakeBinding.copy(signalId = "sig_160_25", signalName = "Speed", - axisPosition = AxisPosition.LEFT_0, unit = "KPH", isVisible = true) - val canBinding = intakeBinding.copy(signalId = "can_7", signalName = "CAN", axisPosition = AxisPosition.RIGHT_0) - val bindings = listOf(intakeBinding, canBinding, speedBinding, - intakeBinding.copy(signalId = "sig_160_30", signalName = "SeatOnly", axisPosition = AxisPosition.LEFT_2)) - val chart = com.dct.app.domain.model.project.DpcChartState( - axes = listOf(com.dct.app.domain.model.chart.ChartAxisConfig(AxisPosition.LEFT_1, -20.0, 80.0, false)), - timeWindowPreset = com.dct.app.domain.model.chart.TimeWindowPreset.SEC_10, legendPosition = 2) - fun windows(prefix: String) = listOf( - com.dct.app.domain.model.project.DpcWindowState("${prefix}_graph", "GRAPH", "Graph", x = 510f, - signals = bindings, chart = chart), - com.dct.app.domain.model.project.DpcWindowState("${prefix}_multi", "MULTI_AXIS_GRAPH", "Multi", - signals = bindings, chart = chart), - com.dct.app.domain.model.project.DpcWindowState("${prefix}_alpha", "ALPHANUMERICAL", "Alpha", - targetSignals = bindings.map { it.signalId }, isTileMode = false, - hiddenSignalIds = setOf("sig_160_12", "sig_160_30"), - highlightColors = mapOf("sig_160_25" to 0x123456L, "sig_160_30" to 0x654321L))) - viewModel.restoreProjectWorkspace(com.dct.app.domain.model.project.DpcWorkspaceState("page_1", listOf( - com.dct.app.domain.model.project.DpcPageState("page_1", "Page 1", windows("one"), signals = bindings), - com.dct.app.domain.model.project.DpcPageState("page_2", "Page 2", windows("two"), signals = bindings, - alphanumericalSignals = bindings.map { it.signalId })))) - val expectedIds = listOf("sig_160_8", "can_7", "sig_160_33") - val canBuffers = (listOf(chartRenderer) + listOf("one_graph", "one_multi", "two_graph", "two_multi") - .map(viewModel::getChartRenderer)).associateWith { renderer -> - renderer.addSampleSec("can_7", 1.0, 123.0) - renderer.addSampleSec("sig_160_12", 1.0, 999.0) - renderer.seriesListFlow.value.first { it.signalId == "can_7" }.buffer - } - val newIntake = intake.copy(idx = 8, cvsA = 0.02, color = 5639644L) - val newSpeed = speed.copy(idx = 33, unit = "km/h", max = 255.0, color = 12478542L) - val replacement = sampleDb.copy(groups = listOf(sampleDb.groups.single().copy( - signals = listOf(newIntake, newSpeed, intake.copy(name = "UnrelatedAtOldId"))))) - every { mdbRepository.getCurrentDatabase() } returns replacement - viewModel.openMdbFromFile(File("HE1i.mdb")) - reconcileMdbProperties(oldSignals) - - canBuffers.forEach { (renderer, buffer) -> - val series = renderer.seriesListFlow.value - assertEquals(expectedIds, series.map { it.signalId }) - assertSame(buffer, series[1].buffer) - assertEquals(1, series[1].buffer.size) - assertEquals(0, series[0].buffer.size) - assertEquals(intakeBinding.axisPosition, series[0].axisPosition) - assertEquals(intakeBinding.lineWidthDp, series[0].lineWidthDp) - assertEquals(intakeBinding.dashStyle, series[0].dashStyle) - assertFalse(series[0].isVisible) - assertEquals(newIntake.color, series[0].color) - } - val saved = viewModel.snapshotWorkspaceForSave() - saved.pages.forEach { page -> - assertEquals(expectedIds, page.signals.map { it.signalId }) - page.windows.filter { it.type == "ALPHANUMERICAL" }.forEach { alpha -> - assertEquals(expectedIds, alpha.targetSignals) - assertFalse(alpha.isTileMode) - assertEquals(setOf("sig_160_8"), alpha.hiddenSignalIds) - assertEquals(mapOf("sig_160_33" to 0x123456L), alpha.highlightColors) - } - page.windows.filter { it.type in listOf("GRAPH", "MULTI_AXIS_GRAPH") }.forEach { graph -> - assertEquals(expectedIds, graph.signals.map { it.signalId }) - val axis = graph.chart!!.axes.first { it.position == AxisPosition.LEFT_1 } - assertEquals(-20.0, axis.minVal) - assertEquals(80.0, axis.maxVal) - assertFalse(axis.isAuto) - assertEquals(newIntake.color, axis.color) - assertEquals(2, graph.chart.legendPosition) - } - } - assertEquals(expectedIds, saved.pages[1].alphanumericalSignals) - assertEquals(listOf("sig_160_8", "sig_160_33"), viewModel.uiState.value.dashboardTiles.map { it.signalId }) - assertEquals("km/h", viewModel.uiState.value.dashboardTiles[1].unit) - assertEquals(0.02, viewModel.uiState.value.mdbDatabase!!.allSignals.first().cvsA) - // A saved/reopened project must refer to the new wire IDs, never the old Seat slots. - val json = com.dct.app.data.repository.DpcProjectManager.serializeProjectToJson( - viewModel.currentProject.value!!.copy(workspace = saved)) - val restored = com.dct.app.data.repository.DpcProjectManager.parseProjectFromJson(json, null) - assertEquals(saved, restored.workspace) - viewModel.restoreProjectWorkspace(restored.workspace) - viewModel.switchPage("page_2") - assertEquals(expectedIds, viewModel.getChartRenderer("two_multi").seriesListFlow.value.map { it.signalId }) - assertEquals(expectedIds, mdiViewModel.uiState.value.windows.first { it.id == "two_alpha" }.config.targetSignals) - } - - @Test fun propertyChangeHandlesSwappedMdbIdsWithoutOverwritingItems() { - viewModel.openMdbFromFile(File("Sample.mdb")) - val oldSignals = sampleDb.allSignals - val window = viewModel.addGraphWindow() - val renderer = viewModel.getChartRenderer(window) - oldSignals.forEachIndexed { index, signal -> - renderer.bindSignal("sig_1_${signal.idx}", signal.name, AxisPosition.LEFT_AXES[index], signal.color) - } - val replacement = sampleDb.copy(groups = sampleDb.groups.map { group -> - group.copy(signals = group.signals.map { it.copy(idx = 3 - it.idx) }) - }) - every { mdbRepository.getCurrentDatabase() } returns replacement - viewModel.openMdbFromFile(File("Swapped.mdb")) - reconcileMdbProperties(oldSignals) - assertEquals(listOf("sig_1_2", "sig_1_1"), renderer.seriesListFlow.value.map { it.signalId }) - assertEquals(oldSignals.map { it.name }, renderer.seriesListFlow.value.map { it.signalName }) - } - - @Test fun propertyChangeUsesFirstExactMdbNameLikePcAndRemovesMissingItems() { - viewModel.openMdbFromFile(File("Sample.mdb")) - val oldSignals = sampleDb.allSignals - val window = viewModel.addGraphWindow() - val renderer = viewModel.getChartRenderer(window) - oldSignals.forEach { renderer.bindSignal("sig_1_${it.idx}", it.name, AxisPosition.LEFT_0, it.color) } - val replacement = sampleDb.copy(groups = sampleDb.groups.map { group -> group.copy(signals = listOf( - oldSignals[0].copy(reclid = 164, idx = 8), oldSignals[0].copy(reclid = 164, idx = 9), - oldSignals[1].copy(idx = 20, name = oldSignals[1].name.lowercase()) - )) }) - every { mdbRepository.getCurrentDatabase() } returns replacement - viewModel.openMdbFromFile(File("Duplicate.mdb")) - reconcileMdbProperties(oldSignals) - assertEquals(listOf("sig_164_8"), renderer.seriesListFlow.value.map { it.signalId }) - every { mdbRepository.getCurrentDatabase() } returns replacement.copy(groups = emptyList()) - viewModel.openMdbFromFile(File("Empty.mdb")) - reconcileMdbProperties(replacement.allSignals) - assertTrue(renderer.seriesListFlow.value.isEmpty()) - } - - private fun reconcileMdbProperties(oldSignals: List) { - MainViewModel::class.java.getDeclaredMethod( - "reconcilePropertySourceBindings", Boolean::class.javaPrimitiveType, - Boolean::class.javaPrimitiveType, Boolean::class.javaPrimitiveType, - List::class.java, List::class.java - ).apply { isAccessible = true }.invoke(viewModel, true, false, false, oldSignals, emptyList()) - } - - @Test fun propertyChangeRemapsA2lByNameWhenAddressChanges() { - val oldSignal = SignalDefinition(0, 1, "Oil_Temp", MdbAttrType.ATTR_2_UINT16, - 1.0, 0.0, "C", 0x123456L, 0.0, 100.0, adl = 10) - val newSignal = oldSignal.copy(idx = 2, adl = 20) - val signalsField = MainViewModel::class.java.getDeclaredField("_a2lSignals").apply { isAccessible = true } - @Suppress("UNCHECKED_CAST") - val signals = signalsField.get(viewModel) as MutableStateFlow> - signals.value = listOf(newSignal) - val window = viewModel.addGraphWindow() - val renderer = viewModel.getChartRenderer(window) - renderer.bindSignal("a2l_10", "Oil_Temp", AxisPosition.LEFT_0, 0x123456L) - renderer.bindSignal("can_7", "CAN", AxisPosition.LEFT_1, 0x334455L) - renderer.addSampleSec("can_7", 1.0, 20.0) - val reconcile = MainViewModel::class.java.getDeclaredMethod( - "reconcilePropertySourceBindings", Boolean::class.javaPrimitiveType, - Boolean::class.javaPrimitiveType, Boolean::class.javaPrimitiveType, - List::class.java, List::class.java - ).apply { isAccessible = true } - reconcile.invoke(viewModel, false, true, false, emptyList(), listOf(oldSignal)) - assertEquals(setOf("a2l_20", "can_7"), renderer.seriesListFlow.value.map { it.signalId }.toSet()) - assertEquals(1, renderer.seriesListFlow.value.first { it.signalId == "can_7" }.buffer.size) - } - - @Test fun characteristicOnlyA2lPropertyChangeKeepsCategoryWindowsAcrossPages( - @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path - ) = runTest { checkCalibrationOnlyA2lPropertyChange(directory, array = false) } - - @Test fun arrayOnlyA2lPropertyChangeKeepsCategoryWindowsAcrossPages( - @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path - ) = runTest { checkCalibrationOnlyA2lPropertyChange(directory, array = true) } - - /** Exercise the real parser and Apply path, including source removal after replacement. */ - private suspend fun checkCalibrationOnlyA2lPropertyChange(directory: java.nio.file.Path, array: Boolean) { - val old = com.dct.app.domain.model.project.DpcProject(a2lFileName = "Old.a2l") - viewModel.setCurrentProject(old) - val categoryTypes = listOf("A2L_MEASUREMENTS", "A2L_CHARACTERISTICS", - "A2L_CHARACTERISTICS_ARRAY", "A2L_SIGNAL_LIST") - fun windows(prefix: String) = categoryTypes.mapIndexed { index, type -> - com.dct.app.domain.model.project.DpcWindowState("${prefix}_$type", type, type, - x = 500f + index * 20f, y = 100f, width = 440f, height = 350f, - contentState = mapOf("selection" to "OldParameter"), - targetParameterName = "OldParameter", targetTableName = "OldTable") - } + listOf( - com.dct.app.domain.model.project.DpcWindowState("${prefix}_graph", "GRAPH", "Graph", - signals = listOf(com.dct.app.domain.model.project.DpcSignalBindingState( - "a2l_10", "OldMeasurement", AxisPosition.LEFT_0, 0x123456L))), - com.dct.app.domain.model.project.DpcWindowState("${prefix}_alpha", "ALPHANUMERICAL", "Alpha", - targetSignals = listOf("a2l_10"))) - val pages = listOf( - com.dct.app.domain.model.project.DpcPageState("page_1", "Page 1", windows("one")), - com.dct.app.domain.model.project.DpcPageState("page_2", "Page 2", windows("two"))) - viewModel.restoreProjectWorkspace(com.dct.app.domain.model.project.DpcWorkspaceState("page_1", pages)) - val replacement = directory.resolve("CalibrationOnly.a2l").toFile().apply { - writeText(""" - /begin PROJECT Test "Test" - /begin MODULE Test "Test" - /begin RECORD_LAYOUT Deposit_UWORD - FNC_VALUES 1 UWORD ROW_DIR DIRECT - /end RECORD_LAYOUT - /begin CHARACTERISTIC NewParameter "New calibration" - ${if (array) "VAL_BLK" else "VALUE"} - 0x70002000 Deposit_UWORD 0.0 NO_COMPU_METHOD 0.0 100.0 - ${if (array) "MATRIX_DIM 4" else ""} - /end CHARACTERISTIC - /end MODULE - /end PROJECT - """.trimIndent()) - } - mockkObject(com.dct.app.data.repository.DpcProjectManager) - try { - every { com.dct.app.data.repository.DpcProjectManager.publishSourceFile(any(), any(), any(), "A2L") } answers { - secondArg() - } - var success: Boolean? = null - val context = mockk(relaxed = true) - viewModel.applyProjectProperties(context, old.copy(a2lFileName = replacement.name, - localA2lPath = replacement.absolutePath)) { success = it }?.join() - assertEquals(true, success, viewModel.uiState.value.statusMessage) - assertTrue(viewModel.a2lSignals.value.isEmpty()) - assertEquals(if (array) emptyList() else listOf("NewParameter"), - viewModel.a2lCharacteristics.value.map { it.name }) - assertEquals(if (array) listOf("NewParameter") else emptyList(), - viewModel.a2lCharacteristicsArray.value.map { it.name }) - val saved = viewModel.snapshotWorkspaceForSave() - saved.pages.forEachIndexed { index, page -> - pages[index].windows.filter { it.type in categoryTypes }.forEach { before -> - val after = page.windows.single { it.id == before.id } - assertEquals(before.type, after.type) - assertEquals(before.x, after.x) - assertEquals(before.y, after.y) - assertEquals(before.width, after.width) - assertEquals(before.height, after.height) - assertTrue(after.contentState.isEmpty()) - assertEquals("", after.targetParameterName) - assertEquals("", after.targetTableName) - } - assertTrue(page.windows.single { it.type == "GRAPH" }.signals.isEmpty()) - assertTrue(page.windows.single { it.type == "ALPHANUMERICAL" }.targetSignals.isEmpty()) - } - viewModel.switchPage("page_2") - assertTrue(categoryTypes.all { type -> mdiViewModel.uiState.value.windows.any { it.type.name == type } }) - - // Removing the entire source still closes its windows on both active and inactive pages. - viewModel.applyProjectProperties(context, viewModel.currentProject.value!!.copy( - a2lFileName = "", localA2lPath = null, a2lFileUri = null)) { success = it }?.join() - assertEquals(true, success) - viewModel.snapshotWorkspaceForSave().pages.forEach { page -> - assertFalse(page.windows.any { it.type in categoryTypes }) - assertTrue(page.windows.any { it.type == "GRAPH" }) - assertTrue(page.windows.any { it.type == "ALPHANUMERICAL" }) - } - } finally { - unmockkObject(com.dct.app.data.repository.DpcProjectManager) - } - } - - @Test fun invalidPropertySourceDoesNotReplaceCurrentProject( - @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path - ) = runTest { - val old = com.dct.app.domain.model.project.DpcProject(mdbFileName = "Sample.mdb") - viewModel.setCurrentProject(old) - val invalid = directory.resolve("broken.mdb").toFile().apply { writeText("not an MDB") } - var success: Boolean? = null - viewModel.applyProjectProperties( - mockk(relaxed = true), old.copy(mdbFileName = "broken.mdb", localMdbPath = invalid.absolutePath) - ) { success = it }?.join() - assertEquals(false, success) - assertEquals(old, viewModel.currentProject.value) - } - - private val testDispatcher = UnconfinedTestDispatcher() - private lateinit var mdbRepository: MdbRepository - private lateinit var packetRepository: PacketRepository - private lateinit var connectionState: MutableStateFlow - private lateinit var recoveryState: MutableStateFlow - private lateinit var repositoryConnected: MutableStateFlow - private lateinit var repositoryStreaming: MutableStateFlow - private lateinit var chartRenderer: ChartBatchRenderer - private lateinit var treeViewModel: SignalTreeViewModel - private lateinit var mdiViewModel: MdiViewModel - private lateinit var calibrationViewModel: CalibrationViewModel - private lateinit var eventBus: EventBus - private lateinit var dispatchers: TestDispatcherProvider - - private lateinit var viewModel: MainViewModel - private lateinit var sampleDb: MdbDatabase - private var backgroundExecutionAllowed = true - private var measurementClockMillis = 0L - - 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))) - assertEquals(original, viewModel.wifiApEndpoint.value) - val pc = com.dct.app.data.communication.WifiApEndpoint("192.168.1.100", 6000) - assertTrue(viewModel.setWifiApEndpoint(pc)) - assertEquals(pc, viewModel.wifiApEndpoint.value) - connectionState.value = DeviceConnectionState.MEASURING - assertFalse(viewModel.setWifiApEndpoint(pc.copy(port = 7000))) - 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) - val ports = mutableListOf() - every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers { - ports += arg(2) - arg<(Boolean, String) -> Unit>(3).invoke(false, host) - } - coEvery { packetRepository.connect(host) } answers { - repositoryConnected.value = true - connectionState.value = DeviceConnectionState.READY - DctResult.Success(Unit) - } - coEvery { packetRepository.disconnect() } answers { - repositoryConnected.value = false - connectionState.value = DeviceConnectionState.DISCONNECTED - DctResult.Success(Unit) - } - viewModel.connectWifi("PC", host, 6000) - assertTrue(viewModel.uiState.value.isConnected) - viewModel.connectWifi("PC", host, 6000) - assertEquals(listOf(6000), ports) - viewModel.connectWifi("PC", host, 7000) - assertEquals(listOf(6000, 7000), ports) - coVerify(exactly = 2) { packetRepository.connect(host) } - } - - @Test fun rejectedForegroundStartDoesNotSendMeasurementStart() = runTest { - connectionState.value = DeviceConnectionState.READY - backgroundExecutionAllowed = false - viewModel.startMeasure() - coVerify(exactly = 0) { packetRepository.startStreaming(any()) } - assertFalse(viewModel.uiState.value.isMeasuring) - assertEquals(DeviceConnectionState.READY, connectionState.value) - } - - @Test fun rejectedForegroundStartDoesNotOpenTcpOrChangeConnectionState() = runTest { - backgroundExecutionAllowed = false - viewModel.connectWifi("127.0.0.1", "6000") - verify(exactly = 0) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } - assertFalse(viewModel.uiState.value.isConnected) - assertEquals(DeviceConnectionState.DISCONNECTED, connectionState.value) - } - - @Test fun closingWindowReleasesItsRendererButSwitchingPagesKeepsIt() { - val id = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! - val renderer = viewModel.getChartRenderer(id) - renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) - renderer.addSampleSec("s", 100.0, 5.0) - viewModel.addPage() - assertSame(renderer, viewModel.getChartRenderer(id)) - assertEquals(1, renderer.seriesListFlow.value.single().buffer.size) - viewModel.switchPage("page_1") - viewModel.closeWorkspaceWindow(id) - assertTrue(renderer.seriesListFlow.value.isEmpty()) - assertTrue(mdiViewModel.uiState.value.windows.none { it.id == id }) - } - - @Test fun deletingPageDisposesItsGraphRenderers() { - val page = viewModel.addPage() - val id = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! - val renderer = viewModel.getChartRenderer(id) - renderer.bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000) - viewModel.removePage(page) - assertTrue(renderer.seriesListFlow.value.isEmpty()) - assertEquals("page_1", viewModel.activePageId.value) - } - - @Test fun unrelatedPacketsDoNotDuplicateGraphSamplesAndReceiveTimeIsPreserved() = runTest { - connectionState.value = DeviceConnectionState.MEASURING - viewModel.handleAddToChart("sm_01_01", "Sensor one", 0xFF0052CCL, "C") - viewModel.handleAddToChart("sm_01_02", "Sensor two", 0xFF0052CCL, "C") - val packets = packetRepository.packetStream as MutableSharedFlow - packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 25, 0, 0), receivedAtMillis = 100_000)) - packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 1, 0, 30, 0, 0), receivedAtMillis = 101_000)) - val first = chartRenderer.seriesListFlow.value.first { it.signalId == "sm_01_01" } - val second = chartRenderer.seriesListFlow.value.first { it.signalId == "sm_01_02" } - assertEquals(1, first.buffer.size) - assertEquals(1, second.buffer.size) - assertEquals(100.0, first.buffer.getLatestTimestamp()) - assertEquals(101.0, second.buffer.getLatestTimestamp()) - assertEquals(25.0, viewModel.liveTelemetryValues.value["sm_01_01"]) - chartRenderer.stop() - } - - @Test fun mixedCanAndSensorPacketsPreserveSamplesOrderAndFractionalReceiveTimes() = runTest { - connectionState.value = DeviceConnectionState.MEASURING - val definition = sampleDb.allSignals.first().copy( - reclid = 0, idx = 7, name = "Speed", messageId = 0x456, - attr = MdbAttrType.ATTR_0_UINT8, startBit = 0, bitLength = 8, - cvsA = 1.0, cvsB = 0.0 - ) - @Suppress("UNCHECKED_CAST") - val definitions = MainViewModel::class.java.getDeclaredField("_canSignals") - .apply { isAccessible = true }.get(viewModel) as MutableStateFlow> - definitions.value = listOf(definition) - val graph = viewModel.getChartRenderer(viewModel.addGraphWindow()).apply { stop() } - val multi = viewModel.getChartRenderer(mdiViewModel.addWindow(MdiWindowType.MULTI_AXIS_GRAPH)!!).apply { stop() } - for (renderer in listOf(graph, multi)) { - renderer.bindSignal("CAN_0x456_SPEED", "Old persisted name", AxisPosition.LEFT_0, 0L) - } - val packets = packetRepository.packetStream as MutableSharedFlow - repeat(60) { index -> - val received = 100.125 + index / 60.0 - packets.emit(DctPacket(0xA3.toByte(), receivedAtSec = received, ingressSequence = index * 2L + 1, - decodedStreamPayload = com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload.Can( - 0, 1, listOf(com.dct.app.data.protocol.DctStreamPayloadParser.CanFrame( - 0x456, byteArrayOf(index.toByte()), true))))) - packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 25, 0, 0), - receivedAtSec = received + 0.001, ingressSequence = index * 2L + 2)) - } - for (renderer in listOf(graph, multi)) { - val buffer = renderer.seriesListFlow.value.single().buffer - assertEquals(60, buffer.size) - repeat(60) { index -> - assertEquals(index.toDouble(), buffer.getValueAt(index)) - assertEquals(100.125 + index / 60.0, buffer.getTimestampAt(index), 0.000000001) - } - } - assertEquals(59.0, viewModel.liveTelemetryValues.value["can_1110_Speed"]) - assertEquals(25.0, viewModel.liveTelemetryValues.value["sm_01_01"]) - } - - @BeforeEach - fun setUp() { - backgroundExecutionAllowed = true - Dispatchers.setMain(testDispatcher) - mdbRepository = mockk(relaxed = true) - packetRepository = mockk(relaxed = true) - dispatchers = TestDispatcherProvider(testDispatcher) - eventBus = EventBus() - - val packetFlow = MutableSharedFlow(extraBufferCapacity = 8) - connectionState = MutableStateFlow(DeviceConnectionState.DISCONNECTED) - recoveryState = MutableStateFlow(com.dct.app.domain.repository.StreamRecoveryState.IDLE) - repositoryConnected = MutableStateFlow(false) - repositoryStreaming = MutableStateFlow(false) - every { packetRepository.packetStream } returns packetFlow - every { packetRepository.connectionState } returns connectionState - every { packetRepository.streamRecovery } returns recoveryState - every { packetRepository.isConnected } returns repositoryConnected - every { packetRepository.isStreaming } returns repositoryStreaming - coEvery { packetRepository.configureMeasurement(any()) } returns DctResult.Success(Unit) - coEvery { packetRepository.startStreaming(any()) } returns DctResult.Success(Unit) - coEvery { packetRepository.stopStreaming() } returns DctResult.Success(Unit) - coEvery { packetRepository.disconnect() } returns DctResult.Success(Unit) - - chartRenderer = ChartBatchRenderer(dispatchers) - treeViewModel = SignalTreeViewModel(dispatchers) - mdiViewModel = MdiViewModel(dispatchers) - calibrationViewModel = CalibrationViewModel(mdbRepository, dispatchers) - - val sig1 = SignalDefinition(1, 1, "Ta_Motor_Speed", MdbAttrType.ATTR_2_UINT16, 0.25, 0.0, "RPM", 0xFF0052CCL, 0.0, 8000.0) - val sig2 = SignalDefinition(1, 2, "Ta_Inverter_Temp", MdbAttrType.ATTR_1_INT8, 1.0, -40.0, "°C", 0xFFBA1A1AL, -40.0, 150.0) - val group = SignalGroup(1, "G1", "모터 그룹", true, signals = listOf(sig1, sig2)) - val const1 = MdbConstant(1, "K_MAX_TORQUE", 350.0, 0x1000L, 0.0, 500.0, MdbAttrType.ATTR_2_UINT16) - - sampleDb = MdbDatabase("Sample.mdb", listOf(group), listOf(const1), emptyList()) - - coEvery { mdbRepository.openDatabase(any()) } returns DctResult.Success(MdbDatabaseInfo("Sample.mdb", "/Sample.mdb", "1MB", "V2000", 1, emptyList())) - every { mdbRepository.getCurrentDatabase() } returns sampleDb - - viewModel = MainViewModel( - mdbRepository = mdbRepository, - chartRenderer = chartRenderer, - treeViewModel = treeViewModel, - mdiViewModel = mdiViewModel, - calibrationViewModel = calibrationViewModel, - eventBus = eventBus, - dispatchers = dispatchers, - wifiSocketManager = mockk(relaxed = true) { - every { isConnected } returns MutableStateFlow(false) - }, // Lifecycle tests must not launch real asynchronous TCP attempts. - packetRepository = packetRepository, - ensureBackgroundExecution = { backgroundExecutionAllowed }, - measurementClock = { measurementClockMillis } - ) - // Rendering cadence is not under test; keep virtual time from advancing forever. - chartRenderer.stop() - } - - @Test fun rawMdbBytesRemainAvailableWithoutPhysicalInversion() = runTest { - sampleDb = sampleDb.copy(groups = listOf(sampleDb.groups.single().copy(signals = listOf( - sampleDb.allSignals.first().copy(cvsA = 0.0, cvsB = 7.0) - )))) - every { mdbRepository.getCurrentDatabase() } returns sampleDb - viewModel.openMdbFromFile(File("Sample.mdb")) - val method = MainViewModel::class.java.getDeclaredMethod("decodeMdbRecord", Int::class.javaPrimitiveType, - ByteArray::class.java, MutableMap::class.java, MutableMap::class.java) - method.isAccessible = true - val values = mutableMapOf() - assertNull(method.invoke(viewModel, 1, byteArrayOf(0x12, 0x34), values, null)) - assertEquals(4660.0, values["raw_sig_1_1"]) - assertEquals(7.0, values["sig_1_1"]) - // A truncated record is diagnostic-only. The record is not returned as a - // fatal decode error, and values already decoded from earlier packets stay intact. - assertNull(method.invoke(viewModel, 1, byteArrayOf(0x12), values, null)) - assertEquals(4660.0, values["raw_sig_1_1"]) - } - - @Test fun descriptionEditingAndWindowPreferencesSurviveSaveAndRestore() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(mdbFileName = "Sample.mdb")) - viewModel.updateParameterDescription("constant", "K_MAX_TORQUE", "새 설명") - assertEquals("새 설명", viewModel.uiState.value.mdbDatabase!!.constants.single().description) - assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) - coVerify(exactly = 0) { packetRepository.writeConstant(any(), any()) } - val id = viewModel.addGraphWindow() - val prefs = mapOf("search" to "RPM", "width:name" to "200.0") - mdiViewModel.updateWindowContentState(id, prefs) - val saved = viewModel.snapshotWorkspaceForSave() - viewModel.restoreProjectWorkspace(saved) - assertEquals(prefs, mdiViewModel.uiState.value.windows.first { it.id == id }.config.contentState) - val descriptions = viewModel.currentProject.value!!.parameterDescriptions - assertEquals("새 설명", descriptions["constant:K_MAX_TORQUE"]) - viewModel.getChartRenderer(id).stop() - chartRenderer.stop() - } - - @Test fun alphaIdsFromDifferentSourcesDoNotCollapseOrDeleteEachOther() { - val id = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)!! - viewModel.addAlphanumericalSignal(id, "can_1") - viewModel.addAlphanumericalSignal(id, "a2l_1") - assertEquals(listOf("can_1", "a2l_1"), mdiViewModel.uiState.value.windows.first { it.id == id }.config.targetSignals) - viewModel.removeAlphanumericalSignal(id, "can_1") - assertEquals(listOf("a2l_1"), mdiViewModel.uiState.value.windows.first { it.id == id }.config.targetSignals) - } - - @Test fun oldWindowDescriptionCallbackCannotModifyNewProject() { - val revision = viewModel.workspaceRevision.value - viewModel.resetWorkspaceForNewProject() - viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject()) - val modules = viewModel.sensorModules.value - viewModel.updateParameterDescription("constant", "K", "old", revision) - viewModel.updateSensorModuleDescription(0, 0, "old", revision) - assertTrue(viewModel.currentProject.value!!.parameterDescriptions.isEmpty()) - assertEquals(modules, viewModel.sensorModules.value) - } - - @Test fun compensationDescriptionsSaveToDpcAndRestoreIncludingEmptyText( - @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path - ) { - val a = CompensationMap(CompensationMapType.TYPE_A, 1, "Shared", 0, MdbAttrType.ATTR_3_INT16, - 0.0, 100.0, "X", 0, MdbAttrType.ATTR_3_INT16, 0.1234, 0.5678, - description = "original A", points = listOf(MapPoint(0, 1, 2, 0.6912))) - val b = a.copy(type = CompensationMapType.TYPE_B, description = "original B") - sampleDb = sampleDb.copy(compensationMaps = listOf(a, b)) - every { mdbRepository.getCurrentDatabase() } returns sampleDb - viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject( - projectFileName = "Descriptions.dpc", mdbFileName = "Sample.mdb")) - viewModel.openMdbFromFile(File("Sample.mdb")) - viewModel.updateParameterDescription("map_TYPE_A", "Shared", "수정한 설명 A") - viewModel.updateParameterDescription("map_TYPE_B", "Shared", "") - val context = mockk(relaxed = true) - mockkObject(com.dct.app.data.repository.DpcProjectManager) - try { - every { com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(any()) } returns directory.toFile() - assertTrue(viewModel.saveActiveProject(context, showFeedback = false)) - val file = directory.resolve("Configuration/Descriptions.dpc").toFile() - assertTrue(file.exists()) - val restored = com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(file)!! - assertEquals("수정한 설명 A", restored.parameterDescriptions["map_TYPE_A:Shared"]) - assertEquals("", restored.parameterDescriptions["map_TYPE_B:Shared"]) - viewModel.resetWorkspaceForNewProject() - viewModel.setCurrentProject(restored) - viewModel.openMdbFromFile(File("Sample.mdb")) - val maps = viewModel.uiState.value.mdbDatabase!!.compensationMaps - assertEquals("수정한 설명 A", maps[0].description) - assertEquals("", maps[1].description) - assertEquals(a.points, maps[0].points) - assertEquals(a.cvsB, maps[0].cvsB) - assertEquals("original A", sampleDb.compensationMaps[0].description) - } finally { - unmockkObject(com.dct.app.data.repository.DpcProjectManager) - } - } - - private suspend fun loadCalibrationArray(directory: java.nio.file.Path, fnc: String = "1 SWORD COLUMN_DIR DIRECT") { - val file = directory.resolve("layout.a2l").toFile().apply { writeText(""" - /begin CHARACTERISTIC Table "table" - VAL_BLK 0x011E0020 AUTO_EXTRACTION_VALUE_S16 0 NO_COMPU_METHOD -32768 32767 - MATRIX_DIM 20 2 - /end CHARACTERISTIC - /begin RECORD_LAYOUT AUTO_EXTRACTION_VALUE_S16 - FNC_VALUES $fnc - /end RECORD_LAYOUT - """.trimIndent()) } - viewModel.openA2lFromFile(file).join() - connectionState.value = DeviceConnectionState.READY - } - - @Test fun arrayWriteUsesPcColumnAddressAndUpdatesOnlyAfterAck( - @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path - ) = runTest { - loadCalibrationArray(directory) - val pending = kotlinx.coroutines.CompletableDeferred>() - coEvery { packetRepository.writeXcpCharacteristic(any(), any(), any()) } coAnswers { pending.await() } - viewModel.updateA2lCharacteristicArrayCell("Table", 1, -123.0) - coVerify(exactly = 1) { packetRepository.writeXcpCharacteristic(0x011E0024L, - com.dct.app.domain.model.a2l.A2lDataType.SWORD, -123.0) } - assertEquals(0.0, viewModel.a2lCharacteristicsArray.value.single().values[1]) - pending.complete(DctResult.Success(Unit)) - assertEquals(-123.0, viewModel.a2lCharacteristicsArray.value.single().values[1]) - coEvery { packetRepository.writeXcpCharacteristic(any(), any(), any()) } returns - DctResult.Failure(com.dct.app.core.error.DctError.Unknown("NACK")) - viewModel.updateA2lCharacteristicArrayCell("Table", 1, -456.0) - assertEquals(-123.0, viewModel.a2lCharacteristicsArray.value.single().values[1]) - } - - @Test fun arrayReadUsesSameLayoutAndSignedTypeAndUpdatesOnlyRequestedCell( - @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path - ) = runTest { - loadCalibrationArray(directory) - coEvery { packetRepository.readXcpCharacteristic(any(), any()) } returns - DctResult.Success(byteArrayOf(0x85.toByte(), 0xFF.toByte())) - viewModel.readA2lCharacteristic("Table", 20) - coVerify(exactly = 1) { packetRepository.readXcpCharacteristic(0x011E0022L, - com.dct.app.domain.model.a2l.A2lDataType.SWORD) } - val values = viewModel.a2lCharacteristicsArray.value.single().values - assertEquals(-123.0, values[20]) - assertEquals(1, values.count { it != 0.0 }) - } - - @Test fun unsupportedArrayLayoutNeverSendsReadOrWrite( - @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path - ) = runTest { - loadCalibrationArray(directory, "1 SWORD COLUMN_DIR PWORD") - viewModel.updateA2lCharacteristicArrayCell("Table", 0, 1.0) - viewModel.readA2lCharacteristic("Table", 0) - coVerify(exactly = 0) { packetRepository.writeXcpCharacteristic(any(), any(), any()) } - coVerify(exactly = 0) { packetRepository.readXcpCharacteristic(any(), any()) } - assertTrue(viewModel.uiState.value.statusMessage!!.contains("간접 주소")) - } - - @Test fun constantResultCallbackWaitsForAckAndReportsBusyAndNack() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - connectionState.value = DeviceConnectionState.READY - val pending = kotlinx.coroutines.CompletableDeferred>() - coEvery { packetRepository.writeConstant(any(), any()) } coAnswers { pending.await() } - val results = mutableListOf() - viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0, onComplete = { results.add(it) }) - assertTrue(viewModel.isCalibrationBusy.value) - assertTrue(results.isEmpty()) - var busyError: String? = null - viewModel.readMdbConstant("K_MAX_TORQUE", onComplete = { busyError = it }) - assertNotNull(busyError) - pending.complete(DctResult.Failure(com.dct.app.core.error.DctError.Unknown("NACK"))) - assertFalse(viewModel.isCalibrationBusy.value) - assertEquals(1, results.size) - assertTrue(results.single()!!.contains("NACK")) - assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) - } - - @Test fun constantReadCompletionSeesUpdatedValueAndOldRevisionCannotWrite() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - connectionState.value = DeviceConnectionState.READY - coEvery { packetRepository.readConstant(any(), any()) } returns DctResult.Success(byteArrayOf(1, 0x90.toByte())) - coEvery { mdbRepository.updateConstant(any(), any()) } returns DctResult.Success(Unit) - var completed = false - viewModel.readMdbConstant("K_MAX_TORQUE", onComplete = { - assertNull(it) - assertEquals(400.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) - completed = true - }) - assertTrue(completed) - assertFalse(viewModel.isCalibrationBusy.value) - val revision = viewModel.workspaceRevision.value - viewModel.resetWorkspaceForNewProject() - var staleError: String? = null - viewModel.updateMdbConstantValue("K_MAX_TORQUE", 500.0, { staleError = it }, revision) - assertNotNull(staleError) - coVerify(exactly = 0) { packetRepository.writeConstant(any(), any()) } - } - - @Test fun constantDescriptionAndDisplayPreferencesPersistToDpc( - @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path - ) { - viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(projectFileName = "Constant.dpc", mdbFileName = "Sample.mdb")) - viewModel.openMdbFromFile(File("Sample.mdb")) - val id = mdiViewModel.addWindow(MdiWindowType.CONSTANT_SIGNAL_LIST)!! - val preferences = mapOf("expanded" to "true", "checked_constants" to "1", "only_checked" to "true", "width:desc" to "245.0", "search" to "Torque") - mdiViewModel.updateWindowContentState(id, preferences) - viewModel.updateParameterDescription("constant", "K_MAX_TORQUE", "프로젝트 설명") - mockkObject(com.dct.app.data.repository.DpcProjectManager) - try { - every { com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(any()) } returns directory.toFile() - assertTrue(viewModel.saveActiveProject(mockk(relaxed = true), showFeedback = false)) - val file = directory.resolve("Configuration/Constant.dpc").toFile() - val restored = com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(file)!! - assertEquals("프로젝트 설명", restored.parameterDescriptions["constant:K_MAX_TORQUE"]) - assertEquals(preferences, restored.workspace!!.pages.flatMap { it.windows }.single { it.id == id }.contentState) - viewModel.updateParameterDescription("constant", "K_MAX_TORQUE", "") - assertTrue(viewModel.saveActiveProject(mockk(relaxed = true), showFeedback = false)) - assertEquals("", com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(file)!!.parameterDescriptions["constant:K_MAX_TORQUE"]) - assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) - coVerify(exactly = 0) { packetRepository.writeConstant(any(), any()) } - } finally { unmockkObject(com.dct.app.data.repository.DpcProjectManager) } - } - - @AfterEach - fun tearDown() { - viewModel.stopMeasure() - viewModel.resetWorkspaceForNewProject() - chartRenderer.stop() - Dispatchers.resetMain() - } - - @Test - fun constantWriteDoesNotChangeUiBeforeAckOrAfterNack() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - connectionState.value = DeviceConnectionState.READY - val pending = kotlinx.coroutines.CompletableDeferred>() - coEvery { packetRepository.writeConstant(any(), any()) } coAnswers { pending.await() } - viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) - assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) - coVerify(exactly = 0) { mdbRepository.updateConstant(any(), any()) } - pending.complete(DctResult.Failure(com.dct.app.core.error.DctError.Unknown("NACK"))) - assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) - assertTrue(viewModel.uiState.value.statusMessage!!.contains("실패")) - coVerify(exactly = 0) { mdbRepository.updateConstant(any(), any()) } - } - - @Test - fun constantWriteUsesBigEndianAndPersistsOnlyOnSuccess() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - connectionState.value = DeviceConnectionState.READY - val bytes = slot() - coEvery { packetRepository.writeConstant(0x1000, capture(bytes)) } returns DctResult.Success(Unit) - coEvery { mdbRepository.updateConstant("K_MAX_TORQUE", 400.0) } returns DctResult.Success(Unit) - viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) - assertArrayEquals(byteArrayOf(1,0x90.toByte()), bytes.captured) - assertEquals(400.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) - coVerify(exactly = 1) { mdbRepository.updateConstant("K_MAX_TORQUE", 400.0) } - } - - @Test - fun calibrationRejectsDisconnectedMeasuringAndInvalidValues() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) - connectionState.value = DeviceConnectionState.MEASURING - viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) - connectionState.value = DeviceConnectionState.READY - for (value in listOf(Double.NaN, 501.0, 400.5)) viewModel.updateMdbConstantValue("K_MAX_TORQUE", value) - coVerify(exactly = 0) { packetRepository.writeConstant(any(), any()) } - assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) - } - - @Test - fun projectSwitchDuringConstantWriteCannotUpdateNewProject() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - connectionState.value = DeviceConnectionState.READY - val pending = kotlinx.coroutines.CompletableDeferred>() - coEvery { packetRepository.writeConstant(any(), any()) } coAnswers { pending.await() } - viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) - viewModel.resetWorkspaceForNewProject() - pending.complete(DctResult.Success(Unit)) - assertNull(viewModel.uiState.value.mdbDatabase) - coVerify(exactly = 0) { mdbRepository.updateConstant(any(), any()) } - } - - @Test - fun constantReadUpdatesUiAndMdbFromDeviceBytes() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - connectionState.value = DeviceConnectionState.READY - coEvery { packetRepository.readConstant(0x1000, 2) } returns DctResult.Success(byteArrayOf(1,0x90.toByte())) - coEvery { mdbRepository.updateConstant("K_MAX_TORQUE", 400.0) } returns DctResult.Success(Unit) - viewModel.readMdbConstant("K_MAX_TORQUE") - assertEquals(400.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) - } - - @Test - fun graphTypeAppliesAcrossPagesAndSurvivesProjectReloadAndNewWindows() { - viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(projectFileName = "GraphType.dpc")) - val firstId = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! - val first = viewModel.getChartRenderer(firstId) - first.bindSignal("rpm", "RPM", AxisPosition.LEFT_0, 0xFF123456, "rpm") - first.addSampleSec("rpm", 1.0, 42.0) - val originalBuffer = first.seriesListFlow.value.single().buffer - viewModel.addPage() - val secondId = mdiViewModel.addWindow(MdiWindowType.MULTI_AXIS_GRAPH)!! - val second = viewModel.getChartRenderer(secondId) - second.bindSignal("temp", "Temperature", AxisPosition.RIGHT_0, 0xFFABCDEF, "C") - - viewModel.setGraphInterpolationMode(InterpolationMode.PWM_STEP) - assertEquals(InterpolationMode.PWM_STEP, viewModel.uiState.value.graphInterpolationMode) - assertFalse(viewModel.currentProject.value!!.graphLinear) - for (renderer in listOf(first, second)) { - assertEquals(InterpolationMode.PWM_STEP, renderer.renderSnapshotFlow.value.seriesList.single().interpolationMode) - } - assertSame(originalBuffer, first.seriesListFlow.value.single().buffer) - assertEquals(42.0, first.seriesListFlow.value.single().buffer.getLatestValue()) - - val saved = viewModel.currentProject.value!!.copy(workspace = viewModel.snapshotWorkspaceForSave()) - assertTrue(saved.workspace!!.pages.flatMap { it.windows }.flatMap { it.signals } - .all { it.interpolationMode == InterpolationMode.PWM_STEP }) - val json = com.dct.app.data.repository.DpcProjectManager.serializeProjectToJson(saved) - val loaded = com.dct.app.data.repository.DpcProjectManager.parseProjectFromJson(json, null) - viewModel.resetWorkspaceForNewProject() - viewModel.setCurrentProject(loaded) - viewModel.restoreProjectWorkspace(loaded.workspace) - for (id in listOf(firstId, secondId)) { - assertEquals(InterpolationMode.PWM_STEP, viewModel.getChartRenderer(id).seriesListFlow.value.single().interpolationMode) - } - val newId = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! - val newRenderer = viewModel.getChartRenderer(newId) - newRenderer.bindSignal("new", "New", AxisPosition.LEFT_0, 0xFF111111) - assertEquals(InterpolationMode.PWM_STEP, newRenderer.seriesListFlow.value.single().interpolationMode) - viewModel.setGraphInterpolationMode(InterpolationMode.LINEAR) - assertTrue(viewModel.currentProject.value!!.graphLinear) - assertEquals(InterpolationMode.LINEAR, newRenderer.renderSnapshotFlow.value.seriesList.single().interpolationMode) - } - - @Test - fun draggedWindowMovesOnceWithRendererSamplesAndSavedLayoutIntact() { - val source = viewModel.activePageId.value - val graph = mdiViewModel.addWindow(MdiWindowType.MULTI_AXIS_GRAPH, width = 640f, height = 400f)!! - val renderer = viewModel.getChartRenderer(graph) - renderer.bindSignal("rpm", "RPM", AxisPosition.RIGHT_1, 0xFF123456, "rpm") - renderer.setAxisRange(AxisPosition.RIGHT_1, 0.0, 9000.0, false) - renderer.addSampleSec("rpm", 1.0, 1234.0) - val buffer = renderer.seriesListFlow.value.single().buffer - val destination = viewModel.addPage() - val other = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)!! - viewModel.switchPage(source) - assertTrue(viewModel.moveWindowToPageAndSwitch(graph, destination, 850f, 360f)) - assertEquals(destination, viewModel.activePageId.value) - assertEquals(graph, mdiViewModel.uiState.value.activeWindowId) - assertSame(renderer, viewModel.getChartRenderer(graph)) - assertSame(buffer, renderer.seriesListFlow.value.single().buffer) - assertEquals(1234.0, buffer.getLatestValue()) - val moved = mdiViewModel.uiState.value.windows.first { it.id == graph } - assertEquals(850f, moved.offsetX) - assertEquals(360f, moved.offsetY) - assertEquals(640f, moved.width) - assertEquals(400f, moved.height) - assertTrue(mdiViewModel.uiState.value.windows.any { it.id == other }) - val saved = viewModel.snapshotWorkspaceForSave() - assertEquals(1, saved.pages.flatMap { it.windows }.count { it.id == graph }) - assertTrue(saved.pages.first { it.id == source }.windows.isEmpty()) - assertEquals("rpm", saved.pages.first { it.id == destination }.windows.first { it.id == graph }.signals.single().signalId) - viewModel.switchPage(source) - assertTrue(mdiViewModel.uiState.value.windows.isEmpty()) - } - - @Test - fun dragCanCrossMultiplePagesAndReturnToOriginalBoundsOnCancel() { - val source = viewModel.activePageId.value - val windowId = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL, offsetX = 30f, offsetY = 40f)!! - val second = viewModel.addPage() - val third = viewModel.addPage() - viewModel.switchPage(source) - assertTrue(viewModel.moveWindowToPageAndSwitch(windowId, second, 60f, 80f)) - assertTrue(viewModel.moveWindowToPageAndSwitch(windowId, third, 90f, 120f)) - assertTrue(viewModel.moveWindowToPageAndSwitch(windowId, source, 30f, 40f)) - val saved = viewModel.snapshotWorkspaceForSave() - assertEquals(source, saved.activePageId) - assertEquals(1, saved.pages.flatMap { it.windows }.count { it.id == windowId }) - assertEquals(30f, mdiViewModel.uiState.value.windows.single().offsetX) - assertEquals(40f, mdiViewModel.uiState.value.windows.single().offsetY) - } - - @Test - fun invalidOrFullDragDestinationDoesNotChangePageOrWindow() { - val source = viewModel.activePageId.value - val windowId = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! - val destination = viewModel.addPage() - repeat(mdiViewModel.uiState.value.maxWindowsLimit) { mdiViewModel.addWindow(MdiWindowType.GRAPH) } - viewModel.switchPage(source) - val before = mdiViewModel.uiState.value - assertFalse(viewModel.moveWindowToPageAndSwitch(windowId, "missing", 0f, 0f)) - assertFalse(viewModel.moveWindowToPageAndSwitch(windowId, destination, Float.NaN, 0f)) - assertFalse(viewModel.moveWindowToPageAndSwitch(windowId, destination, 0f, 0f)) - assertFalse(viewModel.moveWindowToPageAndSwitch(windowId, source, 0f, 0f)) - assertEquals(source, viewModel.activePageId.value) - assertEquals(before, mdiViewModel.uiState.value) - } - - @Test - fun newWorkspaceResetsGraphTypeToLinear() { - viewModel.setGraphInterpolationMode(InterpolationMode.PWM_STEP) - viewModel.resetWorkspaceForNewProject() - assertEquals(InterpolationMode.LINEAR, viewModel.uiState.value.graphInterpolationMode) - chartRenderer.bindSignal("new", "New", AxisPosition.LEFT_0, 0xFF111111) - assertEquals(InterpolationMode.LINEAR, chartRenderer.seriesListFlow.value.single().interpolationMode) - } - - @Test - fun workspaceSnapshotUsesLatestActiveWindowsAndInactiveGraphSettings() { - val graph = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! - val renderer = viewModel.getChartRenderer(graph) - renderer.bindSignal("sig_1_1", "RPM", AxisPosition.LEFT_0, 0xFF123456L, "rpm") - renderer.setSeriesDashStyle("sig_1_1", 3) - renderer.setSeriesVisible("sig_1_1", false) - renderer.setLegendPosition(2) - renderer.setAxisRange(AxisPosition.LEFT_0, 10.0, 500.0, false) - mdiViewModel.updateWindowBounds(graph, 850f, 250f, 600f, 450f) - val revision = viewModel.workspaceRevision.value - val release = viewModel.registerPageViewport("page_1", revision) { 240f to 80f } - val second = viewModel.addPage() - release() - val alpha = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL, - config = com.dct.app.presentation.mdi.MdiWindowConfig( - targetSignals = listOf("sig_1_1"), isTileMode = false, - hiddenSignalIds = setOf("RPM"), highlightColors = mapOf("RPM" to 0xFFABCDEF) - ))!! - val saved = viewModel.snapshotWorkspaceForSave() - assertEquals(second, saved.activePageId) - val first = saved.pages.first() - assertEquals(240f, first.scrollX) - assertEquals(graph, first.activeWindowId) - assertEquals(850f, first.windows.single().x) - assertEquals(3, first.windows.single().signals.single().dashStyle) - assertFalse(first.windows.single().signals.single().isVisible) - assertEquals(2, first.windows.single().chart!!.legendPosition) - - viewModel.resetWorkspaceForNewProject() - viewModel.restoreProjectWorkspace(saved) - assertEquals(alpha, mdiViewModel.uiState.value.activeWindowId) - assertFalse(mdiViewModel.uiState.value.windows.single().config.isTileMode) - viewModel.switchPage("page_1") - assertEquals(graph, mdiViewModel.uiState.value.activeWindowId) - assertEquals(500.0, viewModel.getChartRenderer(graph).exportWorkspaceState().axes.first { it.position == AxisPosition.LEFT_0 }.maxVal) - assertTrue(viewModel.getChartRenderer(graph).seriesListFlow.value.single().buffer.getLatestTimestamp() == null) - } - - @Test - fun savedEmptyPagesStayEmptyAndOldViewportCannotAffectNewProject() { - viewModel.openMdbFromFile(File("Sample.mdb")) - val release = viewModel.registerPageViewport("page_1", viewModel.workspaceRevision.value) { 999f to 999f } - val workspace = com.dct.app.domain.model.project.DpcWorkspaceState("page_1", listOf( - com.dct.app.domain.model.project.DpcPageState("page_1", "Empty 1"), - com.dct.app.domain.model.project.DpcPageState("page_2", "Empty 2") - )) - viewModel.restoreProjectWorkspace(workspace) - release() - viewModel.switchPage("page_2") - viewModel.switchPage("page_1") - assertTrue(mdiViewModel.uiState.value.windows.isEmpty()) - assertEquals(0f, viewModel.snapshotWorkspaceForSave().pages.first().scrollX) - } - - @Test - fun projectLayoutRestoresOnlyAfterSourceLoadCompletes(@org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path) { - val mdb = directory.resolve("Sample.mdb").toFile().apply { writeText("fixture") } - val projectFile = directory.resolve("workspace.dpc").toFile() - val workspace = com.dct.app.domain.model.project.DpcWorkspaceState("page_2", listOf( - com.dct.app.domain.model.project.DpcPageState("page_1", "Empty"), - com.dct.app.domain.model.project.DpcPageState("page_2", "Saved", windows = listOf( - com.dct.app.domain.model.project.DpcWindowState("saved_graph", "GRAPH", "Saved graph") - )) - )) - val project = com.dct.app.domain.model.project.DpcProject( - projectFileName = "workspace.dpc", localMdbPath = mdb.absolutePath, - mdbFileName = "Sample.mdb", workspace = workspace - ) - val gate = kotlinx.coroutines.CompletableDeferred() - coEvery { mdbRepository.openDatabase(mdb) } coAnswers { - gate.await() - DctResult.Success(MdbDatabaseInfo("Sample.mdb", mdb.absolutePath, "1MB", "V2000", 1, emptyList())) - } - val context = mockk(relaxed = true) - mockkObject(com.dct.app.data.repository.DpcProjectManager) - try { - every { com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(projectFile) } returns project - every { com.dct.app.data.repository.DpcProjectManager.publishAvailableProjectSources(context, project) } returns project - assertTrue(viewModel.openProjectFromFile(context, projectFile)) - assertEquals(ProjectLoadState.LOADING, viewModel.projectLoadState.value) - // A second tap while the first source is still being prepared must - // not cancel/restart the original transaction. - assertFalse(viewModel.openProjectFromFile(context, projectFile)) - assertTrue(mdiViewModel.uiState.value.windows.none { it.id == "saved_graph" }) - assertFalse(viewModel.saveActiveProject(context, showFeedback = false)) - gate.complete(Unit) - assertEquals("page_2", viewModel.activePageId.value) - assertEquals(listOf("saved_graph"), mdiViewModel.uiState.value.windows.map { it.id }) - assertNotNull(viewModel.uiState.value.mdbDatabase) - assertEquals(ProjectLoadState.COMPLETED, viewModel.projectLoadState.value) - verify(exactly = 1) { com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(projectFile) } - } finally { - gate.complete(Unit) - unmockkObject(com.dct.app.data.repository.DpcProjectManager) - } - } - - @Test - fun autoSaveIncludesWorkspaceEvenWithoutSourceFiles() { - val context = mockk(relaxed = true) - viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(projectFileName = "Layout.dpc")) - val window = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)!! - mdiViewModel.updateWindowBounds(window, 320f, 180f, 400f, 300f) - val captured = slot() - mockkObject(com.dct.app.data.repository.DpcProjectManager) - try { - every { com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(context) } returns File("DCT Work") - every { com.dct.app.data.repository.DpcProjectManager.getDefaultBaseFolder(context) } returns "DCT Work" - every { com.dct.app.data.repository.DpcProjectManager.getDefaultMeasuredFolder(context) } returns "DCT Work/Data" - every { com.dct.app.data.repository.DpcProjectManager.saveProject(context, capture(captured)) } returns true - assertTrue(viewModel.autoSaveOnExit(context)) - assertEquals(window, captured.captured.workspace!!.pages.single().windows.single().id) - assertEquals(320f, captured.captured.workspace!!.pages.single().windows.single().x) - } finally { - unmockkObject(com.dct.app.data.repository.DpcProjectManager) - } - } - - @Test - @DisplayName("MDB 파일을 로드하고 대시보드 타일을 생성해야 한다") - fun testInitLoadsSampleMdb() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - - val state = viewModel.uiState.value - assertNotNull(state.mdbDatabase) - assertEquals("Sample.mdb", state.mdbFileName) - assertEquals(2, state.dashboardTiles.size) - assertEquals("Ta_Motor_Speed", state.dashboardTiles[0].signalName) - } - - @Test - @DisplayName("READY 장비에서만 계측 시작, 중지 및 토글 상태 전이가 동작해야 한다") - fun testMeasurementLifecycle() { - assertFalse(viewModel.uiState.value.isMeasuring) - viewModel.startMeasure(30) - assertFalse(viewModel.uiState.value.isMeasuring) - - connectionState.value = DeviceConnectionState.READY - repositoryConnected.value = true - viewModel.startMeasure(30) - assertTrue(viewModel.uiState.value.isMeasuring) - // Configuration runs in the Wi-Fi preparation workflow, not each start button press. - coVerify(exactly = 0) { packetRepository.configureMeasurement(any()) } - coVerify { packetRepository.startStreaming(30) } - - viewModel.toggleMeasure() - assertFalse(viewModel.uiState.value.isMeasuring) - coVerify { packetRepository.stopStreaming() } - } - - @Test - @DisplayName("신호 트리에서 차트 바인딩 요청 시 ChartBatchRenderer에 시리즈가 추가되어야 한다") - fun testAddToChartHandling() { - val req = AddToChartRequest( - signalName = "Ta_Motor_Speed", - signalId = "sig_1_1", - color = 0xFF0052CCL, - unit = "RPM", - axisPosition = AxisPosition.LEFT_1 - ) - - viewModel.handleAddToChart(req) - - val seriesList = chartRenderer.seriesListFlow.value - assertEquals(1, seriesList.size) - assertEquals("Ta_Motor_Speed", seriesList[0].signalName) - assertEquals(AxisPosition.LEFT_1, seriesList[0].axisPosition) - } - - @Test - @DisplayName("MDB 신호 색상 변경 시 목록, 타일, 기본/윈도우 그래프에 즉시 동기화되어야 한다") - fun testSignalColorSynchronizesAcrossViews() { - viewModel.openMdbFromFile(File("Sample.mdb")) - val signal = requireNotNull(viewModel.uiState.value.mdbDatabase) - .allSignals.first { it.name == "Ta_Motor_Speed" } - val graphWindowId = viewModel.addGraphWindow() - - viewModel.handleAddToChart( - signalId = "sig_${signal.reclid}_${signal.idx}", - signalName = signal.name, - colorLong = signal.color, - unit = signal.unit, - targetWindowId = graphWindowId - ) - - val changedColor = 0x00A5FFL - viewModel.updateMdbSignalColor(signal = signal, color = changedColor) - - assertEquals( - changedColor, - viewModel.uiState.value.mdbDatabase!!.allSignals.first { it.name == signal.name }.color - ) - assertEquals( - changedColor, - viewModel.uiState.value.dashboardTiles.first { it.signalId == "sig_${signal.reclid}_${signal.idx}" }.color - ) - assertEquals( - changedColor, - chartRenderer.seriesListFlow.value.first { it.signalId == "sig_${signal.reclid}_${signal.idx}" }.color - ) - assertEquals( - changedColor, - viewModel.getChartRenderer(graphWindowId).seriesListFlow.value - .first { it.signalId == "sig_${signal.reclid}_${signal.idx}" }.color - ) - assertEquals( - changedColor, - mdiViewModel.uiState.value.windows.first { it.id == graphWindowId } - .config.boundSignals.first { it.signalId == "sig_${signal.reclid}_${signal.idx}" }.color - ) - } - - @Test - @DisplayName("신호 트리에서 상수를 선택하면 CalibrationViewModel의 상수 모드로 자동 전환되어야 한다") - fun testTreeSelectionRoutingToCalibration() { - val constNode = SignalTreeNode( - id = "con_1", - name = "K_MAX_TORQUE", - type = SignalTreeNodeType.CONSTANT, - rawConstant = sampleDb.constants[0] - ) - - treeViewModel.selectNode(constNode) - - assertEquals(CalibrationEditorMode.CONSTANT, calibrationViewModel.uiState.value.mode) - assertEquals("K_MAX_TORQUE", calibrationViewModel.uiState.value.constantState?.constant?.name) - } - - @Test - @DisplayName("saveCurrentMdb 실행 시 정상 저장 상태가 반환되어야 한다") - fun testSaveCurrentMdb() { - viewModel.openMdbFromFile(File("Sample.mdb")) - val result = viewModel.saveCurrentMdb() - - assertTrue(result is DctResult.Success) - assertNotNull(viewModel.uiState.value.statusMessage) - } - - @Test - @DisplayName("CAN DBC 신호 StateFlow가 초기 상태에서는 빈 리스트를 방출해야 한다") - fun testCanSignalsEmission() { - val signals = viewModel.canSignals.value - assertEquals(0, signals.size) - assertTrue(signals.isEmpty()) - } - - @Test - @DisplayName("openMdbFromUri 호출 시 mdbDatabase 갱신 및 SignalTreeViewModel 동기화가 정상 수행되어야 한다") - fun testOpenMdbFromUriSyncsTreeAndDatabase() = runTest { - val mockContext = mockk(relaxed = true) - val mockUri = mockk(relaxed = true) - - val customDbInfo = MdbDatabaseInfo( - fileName = "Custom_EV_DCT.mdb", - filePath = "/sdcard/Custom_EV_DCT.mdb", - fileSizeFormatted = "1.2 MB", - fileFormat = "V2000", - tableCount = 5, - tables = emptyList() - ) - - val customSig = SignalDefinition(2, 1, "Ta_Battery_SOC", MdbAttrType.ATTR_0_UINT8, 0.5, 0.0, "%", 0xFF009A4CL, 0.0, 100.0) - val customGroup = SignalGroup(2, "BMS", "배터리 그룹", true, signals = listOf(customSig)) - val customDb = MdbDatabase("Custom_EV_DCT.mdb", listOf(customGroup), emptyList(), emptyList()) - - coEvery { mdbRepository.openDatabase(mockContext, mockUri) } returns DctResult.Success(customDbInfo) - every { mdbRepository.getCurrentDatabase() } returns customDb - - viewModel.openMdbFromUri(mockContext, mockUri) - - val state = viewModel.uiState.value - assertEquals("Custom_EV_DCT.mdb", state.mdbFileName) - assertEquals(customDb, state.mdbDatabase) - assertFalse(state.isMdbLoading) - } - - @Test - @DisplayName("계측 시작 시 liveTelemetryValues 맵이 초기화되어야 한다") - fun testLiveTelemetryValuesCalculation() = runTest { - viewModel.openMdbFromFile(File("Sample.mdb")) - connectionState.value = DeviceConnectionState.READY - viewModel.startMeasure(30) - - val telemetry = viewModel.liveTelemetryValues.value - assertNotNull(telemetry) - - viewModel.stopMeasure() - } - - @Test - @DisplayName("saveCurrentMdb(context) 호출 시 MdbRepository.saveToOriginal이 호출되어야 한다") - fun testSaveCurrentMdbWithContext() = runTest { - val mockContext = mockk(relaxed = true) - coEvery { mdbRepository.saveToOriginal(mockContext) } returns DctResult.Success(Unit) - - viewModel.openMdbFromFile(File("Sample.mdb")) - val result = viewModel.saveCurrentMdb(mockContext) - - assertTrue(result is DctResult.Success) - coVerify { mdbRepository.saveToOriginal(mockContext) } - } - - @Test - @DisplayName("멀티 페이지 탭 추가/전환 시 프로젝트 전역 차트 시리즈 및 데이터가 공유/유지되어야 한다") - fun testMultiPageWorkspaceTabManagement() { - val initialPages = viewModel.pages.value - assertEquals(1, initialPages.size) - assertEquals("page_1", viewModel.activePageId.value) - - viewModel.handleAddToChart("sig_1_1", "Ta_Motor_Speed", 0xFF0052CCL, "RPM", isCan = false) - assertEquals(1, chartRenderer.seriesListFlow.value.size) - - val page2Id = viewModel.addPage() - assertEquals(page2Id, viewModel.activePageId.value) - // 탭이 바뀌어도 한 프로젝트를 공유하므로 기존 차트 시리즈와 데이터는 유지됨 - assertEquals(1, chartRenderer.seriesListFlow.value.size) - - viewModel.handleAddToChart("can_5", "CF_Clu_DrivingSpeed", 0xFF00796BL, "km/h", isCan = true) - assertEquals(2, chartRenderer.seriesListFlow.value.size) - - viewModel.switchPage("page_1") - assertEquals("page_1", viewModel.activePageId.value) - assertEquals(2, chartRenderer.seriesListFlow.value.size) - assertTrue(chartRenderer.seriesListFlow.value.any { it.signalName == "Ta_Motor_Speed" }) - assertTrue(chartRenderer.seriesListFlow.value.any { it.signalName == "CF_Clu_DrivingSpeed" }) - - val page3Id = viewModel.addPage() - assertEquals(3, viewModel.pages.value.size) - - viewModel.removePage(page3Id) - assertEquals(2, viewModel.pages.value.size) - } - - @Test - @DisplayName("신호 추가 시 좌측 다중 축(LEFT_0 ~ LEFT_3)으로 순차 자동 할당되어야 한다") - fun testDynamicChartAxisAllocation() { - viewModel.handleAddToChart("sig_1", "MDB_1", 0xFF0052CCL, "V", isCan = false) - viewModel.handleAddToChart("sig_2", "MDB_2", 0xFFBA1A1AL, "A", isCan = false) - - val seriesList = chartRenderer.seriesListFlow.value - assertEquals(2, seriesList.size) - assertEquals(AxisPosition.LEFT_0, seriesList.find { it.signalId == "sig_1" }?.axisPosition) - assertEquals(AxisPosition.LEFT_1, seriesList.find { it.signalId == "sig_2" }?.axisPosition) - - viewModel.handleAddToChart("can_1", "CAN_1", 0xFF388E3CL, "km/h", isCan = true) - viewModel.handleAddToChart("can_2", "CAN_2", 0xFFF57C00L, "°C", isCan = true) - - val updated = chartRenderer.seriesListFlow.value - assertEquals(4, updated.size) - assertEquals(AxisPosition.LEFT_2, updated.find { it.signalId == "can_1" }?.axisPosition) - assertEquals(AxisPosition.LEFT_3, updated.find { it.signalId == "can_2" }?.axisPosition) - } - - @Test - @DisplayName("PC와 같이 MultiAxisGraph는 최대 4개 아이템만 등록하고 일반 Graph는 제한하지 않아야 한다") - fun testPcGraphItemLimits() { - val multiId = mdiViewModel.addWindow(MdiWindowType.MULTI_AXIS_GRAPH)!! - repeat(5) { index -> - viewModel.handleAddToChart( - signalId = "multi_$index", - signalName = "Multi $index", - colorLong = 0xFF000000L + index, - unit = "", - targetWindowId = multiId - ) - } - - val multiSeries = viewModel.getChartRenderer(multiId).seriesListFlow.value - assertEquals(4, multiSeries.size) - assertEquals( - listOf(AxisPosition.LEFT_0, AxisPosition.LEFT_1, AxisPosition.LEFT_2, AxisPosition.LEFT_3), - multiSeries.map { it.axisPosition } - ) - assertFalse(multiSeries.any { it.signalId == "multi_4" }) - assertFalse(chartRenderer.seriesListFlow.value.any { it.signalId == "multi_4" }) - assertEquals("다중축 그래프는 최대 4개까지 지원합니다.", viewModel.uiState.value.statusMessage) - - val graphId = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! - repeat(5) { index -> - viewModel.handleAddToChart( - signalId = "graph_$index", - signalName = "Graph $index", - colorLong = 0xFF100000L + index, - unit = "", - targetWindowId = graphId - ) - } - assertEquals(5, viewModel.getChartRenderer(graphId).seriesListFlow.value.size) - } - - @Test - @DisplayName("MDB 로드 후 A2L 프로젝트로 전환 시 이전 MDB 데이터 및 MDB 서브윈도우가 완전 초기화되어야 한다") - fun testProjectSwitchingMdbToA2lClearsMdbSignals() = runTest { - // 1. MDB 로드 - viewModel.openMdbFromFile(File("Sample.mdb")) - assertNotNull(viewModel.uiState.value.mdbDatabase) - assertEquals("Sample.mdb", viewModel.uiState.value.mdbFileName) - - // 2. A2L 전용 새 프로젝트 생성/설정 - val a2lProject = com.dct.app.domain.model.project.DpcProject( - projectFileName = "a2l_only.dpc", - a2lFileName = "ecu.a2l", - mdbFileName = "" - ) - viewModel.setCurrentProject(a2lProject) - - // 3. 검증: MDB 데이터베이스가 null로 초기화되고 MDB 서브윈도우가 닫혀야 함 - assertNull(viewModel.uiState.value.mdbDatabase) - assertNull(viewModel.uiState.value.mdbFileName) - assertEquals("a2l_only.dpc", viewModel.uiState.value.projectFileName) - } - - @Test - @DisplayName("autoSaveOnExit 호출 시 활성 프로젝트가 있으면 자동 저장이 성공해야 한다") - fun testAutoSaveOnExit() = runTest { - val mockContext = mockk(relaxed = true) - val testProject = com.dct.app.domain.model.project.DpcProject( - projectFileName = "AUTO_TEST", - vehicleCode = "GENESIS", - mdbFileName = "Engine.mdb" - ) - viewModel.setCurrentProject(testProject) - - val result = viewModel.autoSaveOnExit(mockContext) - assertTrue(result) - } - - @Test - @DisplayName("resetWorkspaceForNewProject 호출 시 모든 서브윈도우, 차트, 페이지, 텔레메트리, 신호트리가 완전히 초기화되어야 한다") - fun testResetWorkspaceForNewProjectClearsAllScreensAndStates() = runTest { - // 1. MDB 로드 및 서브윈도우/차트/페이지/계측 상태 생성 - viewModel.openMdbFromFile(File("Sample.mdb")) - mdiViewModel.addGraphWindow() - viewModel.addPage() - viewModel.handleAddToChart( - signalId = "sig_1_1", - signalName = "Ta_Motor_Speed", - colorLong = 0xFF0052CCL, - unit = "RPM" - ) - connectionState.value = DeviceConnectionState.READY - viewModel.startMeasure(30) - - // 사전 상태 확인 - assertTrue(mdiViewModel.uiState.value.windows.isNotEmpty()) - assertTrue(chartRenderer.seriesListFlow.value.isNotEmpty()) - assertTrue(viewModel.pages.value.size > 1) - assertTrue(viewModel.uiState.value.isMeasuring) - - // 2. 화면 및 상태 완전 초기화 호출 - viewModel.resetWorkspaceForNewProject() - - // 3. 완전 초기화 검증 - assertTrue(mdiViewModel.uiState.value.windows.isEmpty(), "MDI 서브윈도우가 모두 닫혀야 함") - assertNull(mdiViewModel.uiState.value.activeWindowId) - assertTrue(chartRenderer.seriesListFlow.value.isEmpty(), "차트 바인딩이 모두 해제되어야 함") - assertEquals(1, viewModel.pages.value.size, "페이지는 Page 1 단일 탭으로 리셋되어야 함") - assertEquals("page_1", viewModel.activePageId.value) - assertFalse(viewModel.uiState.value.isMeasuring, "계측이 중지되어야 함") - assertNull(viewModel.uiState.value.mdbDatabase) - assertNull(viewModel.uiState.value.mdbFileName) - assertNull(viewModel.uiState.value.projectFileName) - assertTrue(viewModel.canSignals.value.isEmpty()) - assertTrue(viewModel.a2lSignals.value.isEmpty()) - assertTrue(viewModel.liveTelemetryValues.value.isEmpty()) - assertTrue(treeViewModel.uiState.value.rootNodes.isEmpty(), "신호 트리 노드가 초기화되어야 함") - } - - @Test - @DisplayName("기존 프로젝트가 열린 상태에서 새 프로젝트 열기 시 화면이 초기화된 후 첫 열(16dp, 264dp)부터 정상 배치되어야 한다") - fun testOpeningNewProjectResetsWindowsFromFirstColumn() = runTest { - // 1. 기존 프로젝트 상태 (여러 윈도우가 우측으로 배치된 상태) - viewModel.openMdbFromFile(File("Sample.mdb")) - mdiViewModel.openOrFocusCategoryWindow(type = MdiWindowType.CAN_SIGNAL_LIST, title = "CAN Signal") - val oldWindowCount = mdiViewModel.uiState.value.windows.size - assertTrue(oldWindowCount >= 2) - - // 2. 새 프로젝트 로드 흐름 (resetWorkspaceForNewProject 후 새 MDB 로드) - viewModel.resetWorkspaceForNewProject() - viewModel.openMdbFromFile(File("Sample.mdb")) - - val newWindows = mdiViewModel.uiState.value.windows - assertEquals(2, newWindows.size) - val signalListWin = newWindows.find { it.type == MdiWindowType.SIGNAL_LIST } - val mdbWin = newWindows.find { it.type == MdiWindowType.MDB_SIGNAL_LIST } - - assertNotNull(signalListWin) - assertNotNull(mdbWin) - assertEquals(16f, signalListWin!!.offsetX, "Signal list는 캔버스 좌측 시작 위치(16dp)에 배치되어야 함") - assertEquals(264f, mdbWin!!.offsetX, "MDB Signal은 두 번째 열(264dp)에 배치되어야 함 (옆으로 밀리지 않음)") - } - - @Test - @DisplayName("전송 계층과 핸드셰이크 완료 전에는 연결 성공 상태를 표시하지 않아야 한다") - fun testCommunicationConnectionAndDisconnectionState() = runTest { - // 1. 초기 상태: 연결 없음 - assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType) - assertFalse(viewModel.uiState.value.isConnected) - assertNull(viewModel.uiState.value.connectedDeviceName) - - // 2. 실제 Bluetooth socket 없이 이름만 선택하면 연결 성공으로 오표시하지 않는다. - viewModel.connectBluetooth("DCT-CAN-BLE-01") - assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType) - assertFalse(viewModel.uiState.value.isConnected) - assertNull(viewModel.uiState.value.connectedDeviceName) - - // 3. 통신 해제 - viewModel.disconnectCommunication() - assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType) - assertFalse(viewModel.uiState.value.isConnected) - assertNull(viewModel.uiState.value.connectedDeviceName) - - // 4. Wi-Fi 연결 요청도 TCP 연결 및 4단계 handshake 전에는 false를 유지한다. - viewModel.connectWifi("DCT_WIFI_CAN_AP", "127.0.0.1") - assertEquals(com.dct.app.presentation.main.CommunicationType.WIFI, viewModel.uiState.value.communicationType) - assertFalse(viewModel.uiState.value.isConnected) - assertEquals("DCT_WIFI_CAN_AP", viewModel.uiState.value.connectedDeviceName) - - // 5. 프로젝트 리셋은 미완료 핸드셰이크를 연결 성공으로 바꾸면 안 된다. - viewModel.resetWorkspaceForNewProject() - assertEquals(com.dct.app.presentation.main.CommunicationType.WIFI, viewModel.uiState.value.communicationType) - assertFalse(viewModel.uiState.value.isConnected) - assertEquals("DCT_WIFI_CAN_AP", viewModel.uiState.value.connectedDeviceName) - - // 6. 명시적 통신 해제 시 비로소 NONE으로 초기화 - viewModel.disconnectCommunication() - assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType) - assertFalse(viewModel.uiState.value.isConnected) - assertNull(viewModel.uiState.value.connectedDeviceName) - } - - @Test - @DisplayName("moveWindowToNextPage 및 moveWindowToPreviousPage 호출 시 현재 보고 있는 페이지는 유지되고 창 및 드랍된 신호 데이터가 온전히 보존되어야 한다") - fun testMoveWindowToNextAndPreviousPage() { - assertEquals(1, viewModel.pages.value.size) - assertEquals("page_1", viewModel.activePageId.value) - - // 1. Page 1에 그래프 창 추가 및 신호 데이터 드랍 - val g1 = viewModel.mdiViewModel.addGraphWindow() - viewModel.handleAddToChart("sig_1_1", "Ta_Motor_Speed", 0xFF0052CCL, "RPM", isCan = false, targetWindowId = g1) - assertEquals(1, chartRenderer.seriesListFlow.value.size) - - val g2 = viewModel.mdiViewModel.addGraphWindow() - assertEquals(2, viewModel.mdiViewModel.uiState.value.windows.size) - - // 2. g1 창을 다음 페이지(Page 2)로 이동 - viewModel.moveWindowToNextPage(g1) - - // 현재 보는 페이지는 여전히 page_1이어야 함 (사용자 화면 유지) - assertEquals("page_1", viewModel.activePageId.value) - // page_1 캔버스에는 g2 창만 남아 있어야 함 - assertEquals(1, viewModel.mdiViewModel.uiState.value.windows.size) - assertEquals(g2, viewModel.mdiViewModel.uiState.value.windows.first().id) - - // Page 2에는 g1 창과 드랍되었던 신호 데이터가 온전히 이동되어 보존되어 있어야 함 - assertEquals(2, viewModel.pages.value.size) - val page2 = viewModel.pages.value[1] - val movedWin = page2.windows.find { it.id == g1 } - assertNotNull(movedWin) - assertTrue(movedWin!!.config.boundSignals.any { it.signalId == "sig_1_1" }) - assertTrue(page2.boundSignals.any { it.signalId == "sig_1_1" }) - - // 3. Page 2로 전환 시 g1의 차트 신호가 복원되어 렌더링되어야 함 - viewModel.switchPage(page2.id) - assertEquals(page2.id, viewModel.activePageId.value) - assertTrue(viewModel.mdiViewModel.uiState.value.windows.any { it.id == g1 }) - assertEquals(1, chartRenderer.seriesListFlow.value.size) - assertEquals("Ta_Motor_Speed", chartRenderer.seriesListFlow.value[0].signalName) - - // 4. g1 창을 다시 Page 1으로 이전 - viewModel.moveWindowToPreviousPage(g1) - assertEquals(page2.id, viewModel.activePageId.value) - assertFalse(viewModel.mdiViewModel.uiState.value.windows.any { it.id == g1 }) - - // 5. Page 1으로 돌아왔을 때 g1 창과 신호 데이터가 온전히 복원됨 - viewModel.switchPage("page_1") - assertEquals("page_1", viewModel.activePageId.value) - assertTrue(viewModel.mdiViewModel.uiState.value.windows.any { it.id == g1 }) - assertEquals(1, chartRenderer.seriesListFlow.value.size) - assertEquals("Ta_Motor_Speed", chartRenderer.seriesListFlow.value[0].signalName) - } - - @Test - @DisplayName("탭(페이지)을 전환하더라도 차트 데이터 및 프로젝트 상태가 유지되며, 그래프 창 생성 번호는 탭에 무관하게 전역 순차 번호가 매겨져야 한다") - fun testProjectSharingAcrossTabsAndGlobalGraphNumbering() { - assertEquals(1, viewModel.pages.value.size) - assertEquals("page_1", viewModel.activePageId.value) - - // 1. Page 1에서 첫 번째 그래프 창 생성 -> GraphWindow 01 - val g1Id = viewModel.addGraphWindow() - val g1Win = viewModel.mdiViewModel.uiState.value.windows.find { it.id == g1Id } - assertNotNull(g1Win) - assertEquals("GraphWindow 01", g1Win!!.title) - - // 신호 바인딩 및 데이터 샘플 주입 - viewModel.handleAddToChart("sig_speed", "Motor_Speed", 0xFF0000L, "RPM", isCan = false, targetWindowId = g1Id) - chartRenderer.addSampleSec("sig_speed", 1.0, 1500.0) - assertEquals(1, chartRenderer.seriesListFlow.value.size) - - // 2. 새 탭 Page 2 생성 및 전환 - val page2Id = viewModel.addPage() - assertEquals(page2Id, viewModel.activePageId.value) - - // 탭이 바뀌어도 이전 탭에서 수집된 차트 신호 및 프로젝트 상태는 공유/보존되어야 함 - assertEquals(1, chartRenderer.seriesListFlow.value.size) - assertEquals("Motor_Speed", chartRenderer.seriesListFlow.value[0].signalName) - - // 3. Page 2에서 새 그래프 창 생성 -> 탭이 달라도 전역 순차 번호 GraphWindow 02가 부여되어야 함 - val g2Id = viewModel.addGraphWindow() - val g2Win = viewModel.mdiViewModel.uiState.value.windows.find { it.id == g2Id } - assertNotNull(g2Win) - assertEquals("GraphWindow 02", g2Win!!.title) - - // 4. 다시 Page 1으로 전환해도 데이터와 창 번호 일관성이 100% 유지됨 - viewModel.switchPage("page_1") - assertEquals("page_1", viewModel.activePageId.value) - assertEquals(1, chartRenderer.seriesListFlow.value.size) - - // 5. Page 1에서 다시 그래프 창을 추가하면 GraphWindow 03이 되어야 함 - val g3Id = viewModel.addGraphWindow() - val g3Win = viewModel.mdiViewModel.uiState.value.windows.find { it.id == g3Id } - assertNotNull(g3Win) - assertEquals("GraphWindow 03", g3Win!!.title) - } - - @Test - @DisplayName("복수의 그래프 윈도우(GraphWindow, MultiAxisGraph 등)에 신호를 드롭했을 때 각 창별로 독립적으로 신호 및 차트가 격리 관리되어야 한다") - fun testGraphWindowsHaveIndependentSeriesAndRenderers() { - // 1. 2개의 그래프 윈도우 생성 (Window 1, Window 2) - val win1Id = viewModel.addGraphWindow() - val win2Id = viewModel.addGraphWindow() - - val r1 = viewModel.getChartRenderer(win1Id) - val r2 = viewModel.getChartRenderer(win2Id) - - // 2. Window 1에만 신호 A 추가 - viewModel.handleAddToChart( - signalId = "sig_speed", - signalName = "Motor_Speed", - colorLong = 0xFF0000L, - unit = "RPM", - isCan = false, - targetWindowId = win1Id - ) - - // 3. Window 2에만 신호 B 추가 - viewModel.handleAddToChart( - signalId = "can_torque", - signalName = "Target_Torque", - colorLong = 0x00FF00L, - unit = "Nm", - isCan = true, - targetWindowId = win2Id - ) - - // 4. 검증: Window 1에는 sig_speed만 존재하고 Target_Torque는 없어야 함 - assertEquals(1, r1.seriesListFlow.value.size) - assertEquals("Motor_Speed", r1.seriesListFlow.value[0].signalName) - assertFalse(r1.seriesListFlow.value.any { it.signalId == "can_torque" }) - - // 5. 검증: Window 2에는 can_torque만 존재하고 Motor_Speed는 없어야 함 - assertEquals(1, r2.seriesListFlow.value.size) - assertEquals("Target_Torque", r2.seriesListFlow.value[0].signalName) - assertFalse(r2.seriesListFlow.value.any { it.signalId == "sig_speed" }) - - // 6. Window 1에서 신호 삭제 시 Window 2에는 영향이 전혀 없어야 함 - viewModel.unbindChartSignal("sig_speed", targetWindowId = win1Id) - assertEquals(0, r1.seriesListFlow.value.size) - assertEquals(1, r2.seriesListFlow.value.size) - assertEquals("Target_Torque", r2.seriesListFlow.value[0].signalName) - - // 7. Window 2 전체 삭제 시 정상 초기화 - viewModel.clearChartSignals(targetWindowId = win2Id) - assertEquals(0, r2.seriesListFlow.value.size) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt b/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt deleted file mode 100644 index f0f99ef..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.DctResult -import com.dct.app.data.maintenance.* -import com.dct.app.domain.model.protocol.* -import com.dct.app.domain.repository.* -import io.mockk.* -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class MaintenanceServiceTest { - @Test fun didReadChecksStatusAndEchoedAddress() = runTest { - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) - coEvery { repo.sendCommand(any()) } returns DctResult.Success(DctPacket.create(DctCommand.RES_REQ_DID_INFO, byteArrayOf(-15,-121,1,2,65,66))) - val service = MaintenanceService(repo) - assertArrayEquals(byteArrayOf(65,66), service.readDid(0xF187)) - assertTrue(runCatching { service.readDid(0xF191) }.isFailure) - coEvery { repo.sendCommand(any()) } returns DctResult.Success(DctPacket.create(DctCommand.RES_REQ_DID_INFO, byteArrayOf(-15,-121,2,0))) - assertTrue(runCatching { service.readDid(0xF187) }.isFailure) - } - @Test fun maintenanceNeverSendsWhileMeasuring() = runTest { - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.MEASURING) - assertTrue(runCatching { MaintenanceService(repo).deleteDatabase(0) }.isFailure) - coVerify(exactly = 0) { repo.sendCommand(any()) } - } - @Test fun databaseListValidatesIndexAndLength() = runTest { - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) - coEvery { repo.sendCommand(any()) } coAnswers { - val request = firstArg() - DctResult.Success(DctPacket.create(DctCommand.RES_DB, - if (request.payload[0] == 1.toByte()) byteArrayOf(1,1,1) else byteArrayOf(2,1,0,2,65,66))) - } - assertEquals(listOf("AB"), MaintenanceService(repo).listDatabases()) - coEvery { repo.sendCommand(match { it.payload[0] == 2.toByte() }) } returns DctResult.Success(DctPacket.create(DctCommand.RES_DB, byteArrayOf(2,1,8,2,65,66))) - assertTrue(runCatching { MaintenanceService(repo).listDatabases() }.isFailure) - } - - @Test fun removeFirmwareValidatesSlotAndHandlesSuccessAndRejection() = runTest { - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) - coEvery { repo.sendCommand(match { it.payload[0] == 0x06.toByte() && it.payload[1] == 1.toByte() }) } returns - DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x06, 0x01))) - - val service = MaintenanceService(repo) - val successResult = service.removeFirmware(1) - assertTrue(successResult.isSuccess) - - // 단말 거절 (0x02) - coEvery { repo.sendCommand(match { it.payload[0] == 0x06.toByte() && it.payload[1] == 2.toByte() }) } returns - DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x06, 0x02))) - val rejectedResult = service.removeFirmware(2) - assertTrue(rejectedResult.isFailure) - - // 슬롯 인덱스 범위 초과 - assertTrue(service.removeFirmware(-1).isFailure) - assertTrue(service.removeFirmware(256).isFailure) - } - - @Test fun triggerFirmwareUpdateHandlesProgressAndCompletion() = runTest { - val repo = mockk() - every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) - val packetStream = kotlinx.coroutines.flow.MutableSharedFlow(replay = 10, extraBufferCapacity = 10) - every { repo.packetStream } returns packetStream - - // 0x07 트리거 성공 응답 - coEvery { repo.sendCommand(match { it.payload[0] == 0x07.toByte() }) } returns - DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x07, 0x01))) - - // 0x08 ACK 송신 모킹 - coEvery { repo.sendCommand(match { it.payload[0] == 0x08.toByte() }) } returns - DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x01))) - - val service = MaintenanceService(repo) - - val updateJob = async { - service.triggerFirmwareUpdate(0) - } - - // 진행 패킷 방출 (50%) - packetStream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x01, 50))) - // 완료 패킷 방출 - packetStream.emit(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x08, 0x02))) - - val result = updateJob.await() - assertTrue(result.isSuccess) - assertEquals(100L, service.transferProgress.value.transferredBytes) - assertFalse(service.transferProgress.value.isRunning) - - // 0x07 시작 거절 시 실패 검증 - coEvery { repo.sendCommand(match { it.payload[0] == 0x07.toByte() }) } returns - DctResult.Success(DctPacket.create(DctCommand.RES_FW_INFO, byteArrayOf(0x07, 0x02))) - assertTrue(service.triggerFirmwareUpdate(0).isFailure) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MdbRecordDecoderTest.kt b/Android/app/src/test/java/com/dct/app/tests/MdbRecordDecoderTest.kt deleted file mode 100644 index d586995..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MdbRecordDecoderTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.protocol.MdbRecordDecoder -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.SignalDefinition -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -@DisplayName("MdbRecordDecoder - uint32_t 인덱스(256 이상) 지원 및 디코딩 무결성 단위 테스트") -class MdbRecordDecoderTest { - - @Test - @DisplayName("[ISSUE-R2-05] 256 이상의 신호 Index(0x0100, 0x0200)가 0xFF 마스킹으로 오인되지 않고 정상 디코딩되어야 한다") - fun testSignalIndexAbove255WithoutMasking() { - // idx = 0과 idx = 256은 0xFF 마스킹 시 둘 다 0이 되어 과거에는 중복 실패했으나, - // 마스킹 제거 후에는 서로 다른 uint32_t 인덱스로 정상 인식되어야 함 - val sigA = SignalDefinition( - reclid = 10, - idx = 0, - name = "Signal_0", - attr = MdbAttrType.ATTR_0_UINT8, - cvsA = 1.0, - cvsB = 0.0, - unit = "", - color = 0L, - min = 0.0, - max = 255.0, - arrayIndex = 0 - ) - val sigB = SignalDefinition( - reclid = 10, - idx = 256, // 0x0100: 과거에는 256 and 0xFF == 0 으로 sigA와 중복 판정됨 - name = "Signal_256", - attr = MdbAttrType.ATTR_2_UINT16, - cvsA = 0.5, - cvsB = 10.0, - unit = "rpm", - color = 0L, - min = 0.0, - max = 32767.0, - arrayIndex = 1 - ) - val sigC = SignalDefinition( - reclid = 10, - idx = 512, // 0x0200: 512 and 0xFF == 0 - name = "Signal_512", - attr = MdbAttrType.ATTR_4_UINT32, - cvsA = 0.1, - cvsB = 0.0, - unit = "km/h", - color = 0L, - min = 0.0, - max = 100000.0, - arrayIndex = 3 - ) - - val definitions = listOf(sigA, sigB, sigC) - // 페이로드 데이터: - // [0]: 0x55 (85) - // [1..2]: 0x03, 0xE8 (1000) - // [3..6]: 0x00, 0x01, 0x86, 0xA0 (100000) - val payload = byteArrayOf( - 0x55.toByte(), - 0x03.toByte(), 0xE8.toByte(), - 0x00.toByte(), 0x01.toByte(), 0x86.toByte(), 0xA0.toByte() - ) - - val result = MdbRecordDecoder.decode(10, payload, definitions) - assertTrue(result is MdbRecordDecoder.DecodeResult.Success, "256 이상 인덱스가 중복 에러 없이 정상 디코딩되어야 합니다.") - - val success = result as MdbRecordDecoder.DecodeResult.Success - assertEquals(3, success.signals.size) - - // sigA 검증 - assertEquals("Signal_0", success.signals[0].definition.name) - assertEquals(85, success.signals[0].rawValue.toInt()) - assertEquals(85.0, success.signals[0].physicalValue) - - // sigB 검증 (1000 * 0.5 + 10.0 = 510.0) - assertEquals("Signal_256", success.signals[1].definition.name) - assertEquals(1000, success.signals[1].rawValue.toInt()) - assertEquals(510.0, success.signals[1].physicalValue) - - // sigC 검증 (100000 * 0.1 = 10000.0) - assertEquals("Signal_512", success.signals[2].definition.name) - assertEquals(100000L, success.signals[2].rawValue.toLong()) - assertEquals(10000.0, success.signals[2].physicalValue) - } - - @Test - @DisplayName("동일한 실제 Index(예: 300과 300)가 존재하는 경우에는 중복 에러로 실패해야 한다") - fun testDuplicateActualIndexFails() { - val sig1 = SignalDefinition( - reclid = 5, - idx = 300, - name = "Sig1", - attr = MdbAttrType.ATTR_0_UINT8, - cvsA = 1.0, - cvsB = 0.0, - unit = "", - color = 0L, - min = 0.0, - max = 255.0, - arrayIndex = 0 - ) - val sig2 = SignalDefinition( - reclid = 5, - idx = 300, // 실제 인덱스 중복 - name = "Sig2", - attr = MdbAttrType.ATTR_0_UINT8, - cvsA = 1.0, - cvsB = 0.0, - unit = "", - color = 0L, - min = 0.0, - max = 255.0, - arrayIndex = 1 - ) - - val result = MdbRecordDecoder.decode(5, byteArrayOf(0x01, 0x02), listOf(sig1, sig2)) - assertTrue(result is MdbRecordDecoder.DecodeResult.Failure) - val failure = result as MdbRecordDecoder.DecodeResult.Failure - assertTrue(failure.reason.contains("Index가 중복됩니다")) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MdbRepositoryCrudTest.kt b/Android/app/src/test/java/com/dct/app/tests/MdbRepositoryCrudTest.kt deleted file mode 100644 index eef1efc..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MdbRepositoryCrudTest.kt +++ /dev/null @@ -1,253 +0,0 @@ -package com.dct.app.tests - -import com.google.common.truth.Truth.assertThat -import com.dct.app.core.common.DctResult -import com.dct.app.core.common.DispatcherProvider -import com.dct.app.data.mdb.VirtualMdbGenerator -import com.dct.app.data.repository.MdbRepositoryImpl -import com.dct.app.domain.model.mdb.MapPoint -import com.dct.app.domain.usecase.* -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File - -@OptIn(ExperimentalCoroutinesApi::class) -@DisplayName("M3 MdbRepository & UseCase CRUD 및 영속성 통합 검증") -class MdbRepositoryCrudTest { - - @TempDir - lateinit var tempDir: File - - private val testDispatcher = StandardTestDispatcher() - private val testDispatchers = object : DispatcherProvider { - override val main: CoroutineDispatcher = testDispatcher - override val mainImmediate: CoroutineDispatcher = testDispatcher - override val io: CoroutineDispatcher = testDispatcher - override val default: CoroutineDispatcher = testDispatcher - override val unconfined: CoroutineDispatcher = testDispatcher - } - - private lateinit var repository: MdbRepositoryImpl - - // UseCases - private lateinit var getMdbDatabaseUseCase: GetMdbDatabaseUseCase - private lateinit var updateConstantUseCase: UpdateConstantUseCase - private lateinit var updateCompensationMapUseCase: UpdateCompensationMapUseCase - private lateinit var saveMdbUseCase: SaveMdbUseCase - private lateinit var createStandardSampleMdbUseCase: CreateStandardSampleMdbUseCase - - @BeforeEach - fun setUp() { - repository = MdbRepositoryImpl(testDispatchers) - getMdbDatabaseUseCase = GetMdbDatabaseUseCase(repository) - updateConstantUseCase = UpdateConstantUseCase(repository) - updateCompensationMapUseCase = UpdateCompensationMapUseCase(repository) - saveMdbUseCase = SaveMdbUseCase(repository) - createStandardSampleMdbUseCase = CreateStandardSampleMdbUseCase(repository) - } - - @AfterEach - fun tearDown() { - repository.close() - } - - // ========================================================================= - // 1. File 및 InputStream 기반 로드 검증 - // ========================================================================= - - @Test - @DisplayName("표준 MDB 파일 로드 및 도메인 객체 트리 구성 검증") - fun testLoadMdbFromFile() = runTest(testDispatcher) { - val mdbFile = File(tempDir, "test_load.mdb") - VirtualMdbGenerator.createStandardDatabase(mdbFile).close() - - val result = getMdbDatabaseUseCase(mdbFile) - assertThat(result).isInstanceOf(DctResult.Success::class.java) - - val db = (result as DctResult.Success).data - assertThat(db.groups).hasSize(3) - assertThat(db.constants).hasSize(4) - assertThat(db.compensationMaps).hasSize(2) - - assertThat(repository.getCurrentDatabase()).isEqualTo(db) - } - - @Test - @DisplayName("InputStream으로부터 MDB 로드 및 도메인 객체 변환 검증") - fun testLoadMdbFromInputStream() = runTest(testDispatcher) { - val outStream = ByteArrayOutputStream() - VirtualMdbGenerator.createStandardDatabase(outStream) - val inStream = ByteArrayInputStream(outStream.toByteArray()) - - val result = getMdbDatabaseUseCase(inStream) - assertThat(result).isInstanceOf(DctResult.Success::class.java) - - val db = (result as DctResult.Success).data - assertThat(db.groups).hasSize(3) - assertThat(db.allSignals).hasSize(10) - } - - // ========================================================================= - // 2. ConLst 상수 CRUD 검증 - // ========================================================================= - - @Test - @DisplayName("ConLst 상수 값(Target_RPM 850 -> 920.0) 및 설명 수정 영속성 검증") - fun testUpdateConstantAndPersistence() = runTest(testDispatcher) { - val mdbFile = File(tempDir, "test_const.mdb") - VirtualMdbGenerator.createStandardDatabase(mdbFile).close() - - getMdbDatabaseUseCase(mdbFile) - - // 1. 상수 값 수정 - val updateRes = updateConstantUseCase("Target_RPM", 920.0, "변경된 목표 RPM") - assertThat(updateRes).isInstanceOf(DctResult.Success::class.java) - - val updatedDb = repository.getCurrentDatabase() - val constItem = updatedDb?.findConstant("Target_RPM") - assertThat(constItem?.data).isEqualTo(920.0) - assertThat(constItem?.description).isEqualTo("변경된 목표 RPM") - - // 2. 다른 상수 TCRmin 수정 - val tcrRes = updateConstantUseCase("TCRmin", 25.0) - assertThat(tcrRes).isInstanceOf(DctResult.Success::class.java) - assertThat(repository.getCurrentDatabase()?.findConstant("TCRmin")?.data).isEqualTo(25.0) - } - - // ========================================================================= - // 3. 1D 보정 맵 포인트 및 메타데이터 수정 검증 - // ========================================================================= - - @Test - @DisplayName("1D 보정 맵 포인트 일괄 수정 및 영속화 검증") - fun testUpdateCompensationMapPoints() = runTest(testDispatcher) { - val mdbFile = File(tempDir, "test_comp_points.mdb") - VirtualMdbGenerator.createStandardDatabase(mdbFile).close() - - getMdbDatabaseUseCase(mdbFile) - - val newPoints = listOf( - MapPoint(idx = 0, bkp = 600, data = 130, phy = 1.30), - MapPoint(idx = 1, bkp = 1200, data = 115, phy = 1.15), - MapPoint(idx = 2, bkp = 2200, data = 102, phy = 1.02), - MapPoint(idx = 3, bkp = 3200, data = 98, phy = 0.98), - MapPoint(idx = 4, bkp = 4200, data = 91, phy = 0.91) - ) - - val updateRes = updateCompensationMapUseCase("MAP_COOLANT_TEMP_COMP", newPoints) - assertThat(updateRes).isInstanceOf(DctResult.Success::class.java) - - val map = repository.getCurrentDatabase()?.findCompensationMap("MAP_COOLANT_TEMP_COMP") - assertThat(map).isNotNull() - assertThat(map?.points).hasSize(5) - assertThat(map?.points?.get(0)?.bkp).isEqualTo(600) - assertThat(map?.points?.get(0)?.data).isEqualTo(130) - assertThat(map?.points?.get(0)?.phy).isEqualTo(1.30) - } - - @Test - @DisplayName("1D 보정 맵 메타데이터(YMIN, YMAX, CVS_A, CVS_B, 설명) 수정 검증") - fun testUpdateCompensationMetadata() = runTest(testDispatcher) { - val mdbFile = File(tempDir, "test_comp_meta.mdb") - VirtualMdbGenerator.createStandardDatabase(mdbFile).close() - - getMdbDatabaseUseCase(mdbFile) - - val metaRes = updateCompensationMapUseCase.updateMetadata( - isTypeA = true, - yName = "MAP_COOLANT_TEMP_COMP", - min = -50.0, - max = 160.0, - cvsA = 1.5, - cvsB = -10.0, - description = "수정된 냉각수 보정 맵" - ) - assertThat(metaRes).isInstanceOf(DctResult.Success::class.java) - - val map = repository.getCurrentDatabase()?.findCompensationMap("MAP_COOLANT_TEMP_COMP") - assertThat(map).isNotNull() - assertThat(map?.yMin).isEqualTo(-50.0) - assertThat(map?.yMax).isEqualTo(160.0) - assertThat(map?.cvsA).isEqualTo(1.5) - assertThat(map?.cvsB).isEqualTo(-10.0) - assertThat(map?.description).isEqualTo("수정된 냉각수 보정 맵") - } - - // ========================================================================= - // 4. SaveMdb 및 OutputStream 내보내기 검증 - // ========================================================================= - - @Test - @DisplayName("수정된 MDB 데이터베이스 OutputStream 내보내기 및 재로드 검증") - fun testSaveMdbToOutputStream() = runTest(testDispatcher) { - val mdbFile = File(tempDir, "test_export_src.mdb") - VirtualMdbGenerator.createStandardDatabase(mdbFile).close() - - getMdbDatabaseUseCase(mdbFile) - updateConstantUseCase("Target_RPM", 999.0) - - val currentDb = repository.getCurrentDatabase()!! - val exportedStream = ByteArrayOutputStream() - - val saveRes = saveMdbUseCase(currentDb, exportedStream) - assertThat(saveRes).isInstanceOf(DctResult.Success::class.java) - - // 내보낸 바이트 스트림을 새 레포지토리에 로드하여 수정값 보존 확인 - val verifyRepo = MdbRepositoryImpl(testDispatchers) - val verifyRes = verifyRepo.loadMdb(ByteArrayInputStream(exportedStream.toByteArray())) - assertThat(verifyRes).isInstanceOf(DctResult.Success::class.java) - - val reloadedDb = (verifyRes as DctResult.Success).data - assertThat(reloadedDb.findConstant("Target_RPM")?.data).isEqualTo(999.0) - verifyRepo.close() - } - - // ========================================================================= - // 5. 표준 샘플 MDB 생성 유스케이스 검증 - // ========================================================================= - - @Test - @DisplayName("CreateStandardSampleMdbUseCase 파일 및 스트림 생성 검증") - fun testCreateStandardSampleDatabaseUseCase() = runTest(testDispatcher) { - // 1. File 대상 생성 - val targetFile = File(tempDir, "standard_created.mdb") - val fileRes = createStandardSampleMdbUseCase(targetFile) - assertThat(fileRes).isInstanceOf(DctResult.Success::class.java) - - val db = (fileRes as DctResult.Success).data - assertThat(db.groups).hasSize(3) - assertThat(targetFile.exists()).isTrue() - assertThat(targetFile.length()).isGreaterThan(0L) - - // 2. OutputStream 대상 생성 - val stream = ByteArrayOutputStream() - val streamRes = createStandardSampleMdbUseCase(stream) - assertThat(streamRes).isInstanceOf(DctResult.Success::class.java) - assertThat(stream.size()).isGreaterThan(0) - } - - // ========================================================================= - // 6. 에러 및 비정상 입력 방어 검증 - // ========================================================================= - - @Test - @DisplayName("존재하지 않는 파일 및 손상된 바이트 로드 시 DctResult.Failure 반환 검증") - fun testErrorHandlingNonExistentAndCorruptFiles() = runTest(testDispatcher) { - val nonExistentFile = File(tempDir, "ghost.mdb") - val ghostRes = getMdbDatabaseUseCase(nonExistentFile) - assertThat(ghostRes).isInstanceOf(DctResult.Failure::class.java) - - val corruptStream = ByteArrayInputStream(byteArrayOf(0x00, 0x01, 0x02, 0x03)) - val corruptRes = getMdbDatabaseUseCase(corruptStream) - assertThat(corruptRes).isInstanceOf(DctResult.Failure::class.java) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MdbSchemaParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/MdbSchemaParserTest.kt deleted file mode 100644 index 177dbec..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MdbSchemaParserTest.kt +++ /dev/null @@ -1,246 +0,0 @@ -package com.dct.app.tests - -import com.google.common.truth.Truth.assertThat -import com.healthmarketscience.jackcess.* -import com.dct.app.data.mdb.MdbSchemaParser -import com.dct.app.data.mdb.VirtualMdbGenerator -import com.dct.app.domain.model.mdb.CompensationMapType -import com.dct.app.domain.model.mdb.MdbAttrType -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File - -@DisplayName("M3 MdbSchemaParser: C++ LibDB.cpp 스키마 파싱 및 오프셋 계산 검증") -class MdbSchemaParserTest { - - @Test - @DisplayName("표준 가상 MDB 파싱: VarNif, VarLst, ConLst, Tb1Dm*, [YNAME] 전수 정합성 검증") - fun testParseStandardVirtualMdb(@TempDir tempDir: File) { - val targetFile = File(tempDir, "standard_test.mdb") - val db = VirtualMdbGenerator.createStandardDatabase(targetFile) - - val parsed = MdbSchemaParser.parseDatabase(db, targetFile.name) - db.close() - - assertThat(parsed.fileName).isEqualTo("standard_test.mdb") - - // 1. VarNif 그룹 검증 - assertThat(parsed.groups).hasSize(3) - val g1 = parsed.groups[0] - assertThat(g1.reclid).isEqualTo(1) - assertThat(g1.description).isEqualTo("Engine_Sensors") - assertThat(g1.startIndex).isEqualTo(0) - assertThat(g1.itemCount).isEqualTo(4) - - val g2 = parsed.groups[1] - assertThat(g2.reclid).isEqualTo(2) - assertThat(g2.description).isEqualTo("Transmission_Sensors") - assertThat(g2.itemCount).isEqualTo(3) - - val g3 = parsed.groups[2] - assertThat(g3.reclid).isEqualTo(3) - assertThat(g3.description).isEqualTo("Climate_Control") - assertThat(g3.itemCount).isEqualTo(3) - - // 2. CalculateVarLstItemIndex 오프셋 누적 검증 - // Group 1 신호 오프셋 검증: - // Sig0: EngineRPM (ATTR 4 -> size 4) => arrayIndex 0 - // Sig1: CoolantTemp (ATTR 2 -> size 2) => arrayIndex 4 - // Sig2: ThrottlePos (ATTR 0 -> size 1) => arrayIndex 6 - // Sig3: IntakePressure (ATTR 2 -> size 2) => arrayIndex 7 - assertThat(g1.signals).hasSize(4) - val rpmSig = g1.signals[0] - assertThat(rpmSig.name).isEqualTo("EngineRPM") - assertThat(rpmSig.attr).isEqualTo(MdbAttrType.ATTR_4_UINT32) - assertThat(rpmSig.itemSize).isEqualTo(4) - assertThat(rpmSig.arrayIndex).isEqualTo(0) - - val tempSig = g1.signals[1] - assertThat(tempSig.name).isEqualTo("CoolantTemp") - assertThat(tempSig.attr).isEqualTo(MdbAttrType.ATTR_2_UINT16) - assertThat(tempSig.itemSize).isEqualTo(2) - assertThat(tempSig.arrayIndex).isEqualTo(4) - - val throttleSig = g1.signals[2] - assertThat(throttleSig.name).isEqualTo("ThrottlePos") - assertThat(throttleSig.attr).isEqualTo(MdbAttrType.ATTR_0_UINT8) - assertThat(throttleSig.itemSize).isEqualTo(1) - assertThat(throttleSig.arrayIndex).isEqualTo(6) - - val pressureSig = g1.signals[3] - assertThat(pressureSig.name).isEqualTo("IntakePressure") - assertThat(pressureSig.attr).isEqualTo(MdbAttrType.ATTR_2_UINT16) - assertThat(pressureSig.itemSize).isEqualTo(2) - assertThat(pressureSig.arrayIndex).isEqualTo(7) - - // Group 2 신호 오프셋 검증: - // Sig0: VehicleSpeed (ATTR 2 -> size 2) => arrayIndex 0 - // Sig1: ClutchPressure (ATTR 2 -> size 2) => arrayIndex 2 - // Sig2: GearPosition (ATTR 1 -> size 1) => arrayIndex 4 - assertThat(g2.signals).hasSize(3) - assertThat(g2.signals[0].arrayIndex).isEqualTo(0) - assertThat(g2.signals[1].arrayIndex).isEqualTo(2) - assertThat(g2.signals[2].arrayIndex).isEqualTo(4) - - // 3. ConLst 상수 검증 - assertThat(parsed.constants).hasSize(4) - val tcr = parsed.findConstant("TCRmin") - assertThat(tcr).isNotNull() - assertThat(tcr?.data).isEqualTo(10.0) - assertThat(tcr?.addr).isEqualTo(0x1000L) - assertThat(tcr?.attr).isEqualTo(MdbAttrType.ATTR_2_UINT16) - - val targetRpm = parsed.findConstant("Target_RPM") - assertThat(targetRpm).isNotNull() - assertThat(targetRpm?.data).isEqualTo(850.0) - assertThat(targetRpm?.min).isEqualTo(500.0) - assertThat(targetRpm?.max).isEqualTo(6000.0) - - // 4. CompensationMap Type A & Type B 검증 - assertThat(parsed.compensationMaps).hasSize(2) - - val typeAMap = parsed.findCompensationMap("MAP_COOLANT_TEMP_COMP") - assertThat(typeAMap).isNotNull() - assertThat(typeAMap?.type).isEqualTo(CompensationMapType.TYPE_A) - assertThat(typeAMap?.isTypeA).isTrue() - assertThat(typeAMap?.xName).isEqualTo("ENGINE_SPEED") - assertThat(typeAMap?.points).hasSize(5) - assertThat(typeAMap?.points?.get(0)?.bkp).isEqualTo(500) - assertThat(typeAMap?.points?.get(0)?.phy).isEqualTo(1.20) - - val typeBMap = parsed.findCompensationMap("CURVE_BLOWER_PWM") - assertThat(typeBMap).isNotNull() - assertThat(typeBMap?.type).isEqualTo(CompensationMapType.TYPE_B) - assertThat(typeBMap?.isTypeB).isTrue() - assertThat(typeBMap?.xName).isEqualTo("CABIN_TEMP_DIFF") - assertThat(typeBMap?.points).hasSize(4) - assertThat(typeBMap?.points?.get(3)?.bkp).isEqualTo(15) - assertThat(typeBMap?.points?.get(3)?.phy).isEqualTo(100.0) - } - - @Test - @DisplayName("VarNif 부재 시 VarLst의 RECLID 기반 자동 그룹 생성 검증") - fun testFallbackWhenVarNifMissing(@TempDir tempDir: File) { - val targetFile = File(tempDir, "varnif_missing.mdb") - val db = DatabaseBuilder.create(Database.FileFormat.V2000, targetFile) - - // VarLst 테이블만 단독 생성 - val tbVarLst = TableBuilder("VarLst") - .addColumn(ColumnBuilder("RECLID", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("NAME", DataType.TEXT).setLength(255).toColumn()) - .addColumn(ColumnBuilder("ATTR", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("CVS_A", DataType.DOUBLE).toColumn()) - .addColumn(ColumnBuilder("CVS_B", DataType.DOUBLE).toColumn()) - .toTable(db) - - tbVarLst.addRow(10L, 0L, "SensorA", 2L, 1.0, 0.0) - tbVarLst.addRow(10L, 1L, "SensorB", 4L, 1.0, 0.0) - tbVarLst.addRow(20L, 0L, "SensorC", 0L, 1.0, 0.0) - db.flush() - - val parsed = MdbSchemaParser.parseDatabase(db, targetFile.name) - db.close() - - assertThat(parsed.groups).hasSize(2) - assertThat(parsed.groups[0].reclid).isEqualTo(10) - assertThat(parsed.groups[0].signals).hasSize(2) - assertThat(parsed.groups[0].signals[0].arrayIndex).isEqualTo(0) - assertThat(parsed.groups[0].signals[1].arrayIndex).isEqualTo(2) - - assertThat(parsed.groups[1].reclid).isEqualTo(20) - assertThat(parsed.groups[1].signals).hasSize(1) - assertThat(parsed.groups[1].signals[0].arrayIndex).isEqualTo(0) - } - - @Test - @DisplayName("포인트 테이블의 ID 컬럼명(IDX vs ID) 및 DESCRIPTION 컬럼 누락 방어 검증") - fun testPointTableColumnNameVariantsAndMissingDescription(@TempDir tempDir: File) { - val targetFile = File(tempDir, "variants.mdb") - val db = DatabaseBuilder.create(Database.FileFormat.V2000, targetFile) - - // Tb1DmPxDyLst (DESCRIPTION 컬럼 없음) - val tbPxDy = TableBuilder("Tb1DmPxDyLst") - .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("YNAME", DataType.TEXT).setLength(50).toColumn()) - .addColumn(ColumnBuilder("YADDR", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("YATTR", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("YMIN", DataType.DOUBLE).toColumn()) - .addColumn(ColumnBuilder("YMAX", DataType.DOUBLE).toColumn()) - .addColumn(ColumnBuilder("XNAME", DataType.TEXT).setLength(50).toColumn()) - .addColumn(ColumnBuilder("XADDR", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("XATTR", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("CVS_A", DataType.DOUBLE).toColumn()) - .addColumn(ColumnBuilder("CVS_B", DataType.DOUBLE).toColumn()) - .toTable(db) - - tbPxDy.addRow(0L, "MAP_CUSTOM", 0x1000L, 2L, 0.0, 100.0, "MAP_X", 0x2000L, 2L, 1.0, 0.0) - - // MAP_CUSTOM 테이블 (첫 컬럼명이 ID) - val tbMap = TableBuilder("MAP_CUSTOM") - .addColumn(ColumnBuilder("ID", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("BKP", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("DATA", DataType.LONG).toColumn()) - .addColumn(ColumnBuilder("PHY", DataType.DOUBLE).toColumn()) - .toTable(db) - - tbMap.addRow(1L, 100L, 10L, 1.0) - tbMap.addRow(2L, 200L, 20L, 2.0) - db.flush() - - val parsed = MdbSchemaParser.parseDatabase(db, targetFile.name) - db.close() - - val map = parsed.findCompensationMap("MAP_CUSTOM") - assertThat(map).isNotNull() - assertThat(map?.points).hasSize(2) - assertThat(map?.points?.get(0)?.idx).isEqualTo(1) - assertThat(map?.points?.get(0)?.bkp).isEqualTo(100) - } - - @Test - fun testInspectRealMdbAndDbc() { - val mdbFile = File("C:/Users/COMPUTER1/.gemini/antigravity/brain/8a1b68ca-0b32-4fa8-acae-6f6d866f992b/test_sample.mdb") - if (mdbFile.exists()) { - val db = DatabaseBuilder.open(mdbFile) - println("=== MDB TABLES: ${db.tableNames} ===") - val varLst = db.getTable("VarLst") - if (varLst != null) { - println("=== VarLst COLUMNS: ${varLst.columns.map { it.name }} ===") - var count = 0 - for (row in varLst) { - if (count++ < 15) { - val name = row.get("NAME") - val color = row.get("COLOR") - val desc = row.get("DESCRIPTION") - val descrip = row.get("DESCRIP") - val reclid = row.get("RECLID") - val idx = row.get("IDX") - println("MDB VarLst Sig: name=$name, reclid=$reclid, idx=$idx, COLOR=$color (0x${java.lang.Long.toHexString((color as? Number)?.toLong() ?: 0L)}), desc=$desc, descrip=$descrip") - } - } - } - val varNif = db.getTable("VarNif") - if (varNif != null) { - println("=== VarNif COLUMNS: ${varNif.columns.map { it.name }} ===") - for (row in varNif) { - println("VarNif Row: RECLID=${row.get("RECLID")}, IDX=${row.get("IDX")}, DESCRIP=${row.get("DESCRIP")}") - } - } - db.close() - } - - val dbcFile = File("C:/Users/COMPUTER1/.gemini/antigravity/brain/8a1b68ca-0b32-4fa8-acae-6f6d866f992b/test_sample.dbc") - if (dbcFile.exists()) { - dbcFile.inputStream().use { stream -> - val signals = com.dct.app.data.parser.CanDbcParser.parseDbc(stream) - println("=== DBC SIGNALS COUNT: ${signals.size} ===") - for (sig in signals.take(15)) { - println("DBC Sig: name=${sig.name}, color=0x${java.lang.Long.toHexString(sig.color)}, desc='${sig.description}', msg=${sig.messageName}") - } - } - } - } -} - diff --git a/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt deleted file mode 100644 index 13a8022..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt +++ /dev/null @@ -1,247 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.DefaultDispatcherProvider -import com.dct.app.presentation.mdi.MdiViewModel -import com.dct.app.presentation.mdi.MdiWindowConfig -import com.dct.app.presentation.mdi.MdiWindowType -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -@DisplayName("M5 - MdiWorkspaceManager MDI 서브윈도우 관리자 단위 테스트") -class MdiWorkspaceManagerTest { - - private lateinit var dispatchers: DefaultDispatcherProvider - private lateinit var viewModel: MdiViewModel - - @BeforeEach - fun setUp() { - dispatchers = DefaultDispatcherProvider() - viewModel = MdiViewModel(dispatchers) - } - - @Test - @DisplayName("초기 상태에서 윈도우 목록은 비어 있고 activeWindowId는 null이어야 한다") - fun testInitialState() { - val state = viewModel.uiState.value - assertTrue(state.windows.isEmpty()) - assertNull(state.activeWindowId) - } - - @Test - fun compensationDefaultsFitPointColumnsAndPreserveResizedWindows() { - listOf(MdiWindowType.COMPENSATION_TYPE_A, MdiWindowType.COMPENSATION_TYPE_B).forEach { type -> - val id = viewModel.openOrFocusCategoryWindow(type) - val window = viewModel.uiState.value.windows.first { it.id == id } - assertEquals(800f, window.width) - assertEquals(620f, window.height) - assertTrue(window.width - 12f - 6f - 300f >= 400f) - viewModel.updateWindowBounds(id, window.offsetX, window.offsetY, 800f, 700f) - assertEquals(id, viewModel.openOrFocusCategoryWindow(type)) - val resized = viewModel.uiState.value.windows.first { it.id == id } - assertEquals(800f, resized.width) - assertEquals(700f, resized.height) - } - } - - @Test - @DisplayName("새 서브윈도우 탭을 추가하면 윈도우 목록이 증가하고 새로 추가된 탭이 활성화되어야 한다") - fun testAddWindow() { - val createdId = viewModel.addWindow( - type = MdiWindowType.CALIBRATION, - title = "제어기 캘리브레이션 1" - ) - - assertNotNull(createdId) - val state = viewModel.uiState.value - assertEquals(1, state.windows.size) - assertEquals(createdId, state.activeWindowId) - assertEquals("제어기 캘리브레이션 1", state.activeWindow?.title) - assertEquals(MdiWindowType.CALIBRATION, state.activeWindow?.type) - } - - @Test - @DisplayName("윈도우 탭을 닫으면 목록에서 제거되어야 한다") - fun testCloseWindow() { - val id1 = viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") - val id2 = viewModel.addWindow(type = MdiWindowType.CALIBRATION, title = "Calib") - assertNotNull(id1) - assertNotNull(id2) - assertEquals(2, viewModel.uiState.value.windows.size) - - viewModel.closeWindow(id2!!) - val state = viewModel.uiState.value - assertEquals(1, state.windows.size) - assertEquals(id1, state.activeWindowId) - } - - @Test - @DisplayName("닫기 불가(isClosable = false)로 설정된 윈도우는 닫히지 않아야 한다") - fun testUnclosableWindow() { - val id = viewModel.addWindow( - type = MdiWindowType.CHART, - title = "Primary Chart", - isClosable = false - ) - assertNotNull(id) - assertEquals(1, viewModel.uiState.value.windows.size) - - viewModel.closeWindow(id!!) - // isClosable=false이므로 닫히지 않음 - assertEquals(1, viewModel.uiState.value.windows.size) - } - - @Test - @DisplayName("서브윈도우 탭 전환(selectWindow)이 정상적으로 동작해야 한다") - fun testSelectWindow() { - val id1 = viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") - val id2 = viewModel.addWindow(type = MdiWindowType.DASHBOARD, title = "Dashboard") - - // id2가 활성화된 상태에서 id1으로 전환 - viewModel.selectWindow(id1!!) - assertEquals(id1, viewModel.uiState.value.activeWindowId) - - viewModel.selectWindow(id2!!) - assertEquals(id2, viewModel.uiState.value.activeWindowId) - } - - @Test - @DisplayName("서브윈도우 제목 변경(renameWindow)이 정상적으로 동작해야 한다") - fun testRenameWindow() { - val id = viewModel.addWindow(type = MdiWindowType.CHART, title = "Original") - assertNotNull(id) - - viewModel.renameWindow(id!!, "Renamed Chart") - val win = viewModel.uiState.value.windows.find { it.id == id } - assertEquals("Renamed Chart", win?.title) - } - - @Test - @DisplayName("윈도우 설정(config) 업데이트가 정상적으로 반영되어야 한다") - fun testUpdateWindowConfig() { - val id = viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") - assertNotNull(id) - - val newConfig = MdiWindowConfig(autoScroll = false, targetTableName = "TestTable") - viewModel.updateWindowConfig(id!!, newConfig) - - val win = viewModel.uiState.value.windows.find { it.id == id } - assertNotNull(win) - assertEquals(false, win?.config?.autoScroll) - assertEquals("TestTable", win?.config?.targetTableName) - } - - @Test - @DisplayName("최대 윈도우 개수(10개) 한도를 초과하여 윈도우를 추가할 수 없어야 한다") - fun testMaxWindowLimit() { - repeat(10) { i -> - viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart $i") - } - assertEquals(10, viewModel.uiState.value.windows.size) - - // 11번째 시도 - null 반환 - val extra = viewModel.addWindow(type = MdiWindowType.CHART, title = "Extra") - assertNull(extra) - assertEquals(10, viewModel.uiState.value.windows.size) - } - - @Test - @DisplayName("getOrCreateWindow는 기존 윈도우가 있으면 해당 윈도우를 포커스하고 없으면 생성해야 한다") - fun testGetOrCreateWindow() { - val id1 = viewModel.getOrCreateWindow(type = MdiWindowType.CHART, title = "Chart") - assertNotNull(id1) - assertEquals(1, viewModel.uiState.value.windows.size) - - // 같은 타입으로 다시 요청 → 기존 것 포커스 - val id2 = viewModel.getOrCreateWindow(type = MdiWindowType.CHART, title = "Chart") - assertEquals(id1, id2) - assertEquals(1, viewModel.uiState.value.windows.size) - - // 다른 타입 → 새로 생성 - val id3 = viewModel.getOrCreateWindow(type = MdiWindowType.CALIBRATION, title = "Calib") - assertNotNull(id3) - assertNotEquals(id1, id3) - assertEquals(2, viewModel.uiState.value.windows.size) - } - - @Test - @DisplayName("moveWindow 및 resizeWindow를 통한 실시간 드래그/리사이즈가 정확하게 갱신되어야 한다") - fun testMoveAndResizeWindow() { - val id = viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") - assertNotNull(id) - val initialWin = viewModel.uiState.value.windows.first() - - // 1. 이동 델타 적용 (+20, +30) - viewModel.moveWindow(id!!, 20f, 30f) - val movedWin = viewModel.uiState.value.windows.first() - assertEquals(initialWin.offsetX + 20f, movedWin.offsetX) - assertEquals(initialWin.offsetY + 30f, movedWin.offsetY) - - // 2. 크기 변경 델타 적용 (+50, +40) - viewModel.resizeWindow(id, 50f, 40f) - val resizedWin = viewModel.uiState.value.windows.first() - assertEquals(initialWin.width + 50f, resizedWin.width) - assertEquals(initialWin.height + 40f, resizedWin.height) - } - - @Test - @DisplayName("closeWindowsByTypes 호출 시 지정된 타입들의 윈도우가 일괄 닫혀야 한다") - fun testCloseWindowsByTypes() { - viewModel.addWindow(type = MdiWindowType.MDB_SIGNAL_LIST, title = "MDB Sig") - viewModel.addWindow(type = MdiWindowType.CONSTANT_SIGNAL_LIST, title = "Const Sig") - viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") - assertEquals(3, viewModel.uiState.value.windows.size) - - viewModel.closeWindowsByTypes(MdiWindowType.MDB_SIGNAL_LIST, MdiWindowType.CONSTANT_SIGNAL_LIST) - val remaining = viewModel.uiState.value.windows - assertEquals(1, remaining.size) - assertEquals(MdiWindowType.CHART, remaining.first().type) - } - - @Test - @DisplayName("addGraphWindow 호출 시 GraphWindow 01, GraphWindow 02, GraphWindow 03 등 순차 번호가 올바르게 부여되어야 한다") - fun testAddGraphWindowNumbered() { - val g1 = viewModel.addGraphWindow() - val g2 = viewModel.addGraphWindow() - val g3 = viewModel.addGraphWindow() - - val windows = viewModel.uiState.value.windows - assertEquals(3, windows.size) - assertEquals("GraphWindow 01", windows[0].title) - assertEquals("GraphWindow 02", windows[1].title) - assertEquals("GraphWindow 03", windows[2].title) - - // 중간의 GraphWindow 02를 닫고 다시 추가하면 미사용 번호(GraphWindow 02)가 재사용되어야 한다 - viewModel.closeWindow(g2) - assertEquals(2, viewModel.uiState.value.windows.size) - - val g2New = viewModel.addGraphWindow() - val updatedWindows = viewModel.uiState.value.windows - assertEquals(3, updatedWindows.size) - assertTrue(updatedWindows.any { it.title == "GraphWindow 02" }) - } - - @Test - @DisplayName("뷰포트 스크롤 좌표가 주어졌을 때 addGraphWindow와 openOrFocusCategoryWindow는 현재 화면 영역 내에 창을 배치해야 한다") - fun testWindowPlacementWithViewportCoordinates() { - val graphId = viewModel.addGraphWindow( - viewportScrollX = 500f, - viewportScrollY = 300f - ) - val graphWin = viewModel.uiState.value.windows.find { it.id == graphId } - assertNotNull(graphWin) - assertTrue(graphWin!!.offsetX >= 500f) - assertTrue(graphWin.offsetY >= 300f) - - val catId = viewModel.openOrFocusCategoryWindow( - type = MdiWindowType.CAN_SIGNAL_LIST, - viewportScrollX = 800f, - viewportScrollY = 400f - ) - val catWin = viewModel.uiState.value.windows.find { it.id == catId } - assertNotNull(catWin) - assertTrue(catWin!!.offsetX >= 800f) - assertTrue(catWin.offsetY >= 400f) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt deleted file mode 100644 index 9535fd3..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.DctResult -import com.dct.app.core.error.DctError -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.domain.repository.PacketRepository -import com.dct.app.domain.repository.StreamRecoveryState -import com.dct.app.presentation.main.MainViewModel -import io.mockk.coEvery -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* - -@Suppress("UNCHECKED_CAST") -class MeasurementElapsedTimeTest { - private lateinit var fixture: MainViewModelTest - private lateinit var vm: MainViewModel - private lateinit var repository: PacketRepository - private lateinit var connection: MutableStateFlow - private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } - private fun clock(time: Long) = field(fixture, "measurementClockMillis").setLong(fixture, time) - - @BeforeEach fun setUp() { - fixture = MainViewModelTest().also { it.setUp() } - vm = field(fixture, "viewModel").get(fixture) as MainViewModel - repository = field(fixture, "packetRepository").get(fixture) as PacketRepository - connection = field(fixture, "connectionState").get(fixture) as MutableStateFlow - connection.value = DeviceConnectionState.READY - } - - @AfterEach fun cleanUp() { - vm.scope.cancel() - fixture.tearDown() - } - - @Test fun timerStartsOnlyAfterSuccessfulStartNotWhileWaitingForResponse() { - val response = CompletableDeferred>() - coEvery { repository.startStreaming(any()) } coAnswers { response.await() } - clock(1_000) - vm.startMeasure() - assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) - clock(50_000) - response.complete(DctResult.Success(Unit)) - assertTrue(vm.uiState.value.isMeasuring) - assertEquals(50_000L, vm.uiState.value.measurementStartedAtElapsedMillis) - } - - @Test fun failedStartDoesNotCreateTimer() { - coEvery { repository.startStreaming(any()) } returns DctResult.Failure(DctError.ProtocolError.Disconnected) - clock(5_000) - vm.startMeasure() - assertFalse(vm.uiState.value.isMeasuring) - assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) - } - - @Test fun stopClearsTimeAndSuccessfulRestartStartsFromNewTime() { - clock(1_000) - vm.startMeasure() - clock(61_000) - vm.stopMeasure() - assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) - clock(99_000) - vm.stopMeasure() - assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) - clock(120_000) - vm.startMeasure() - assertEquals(120_000L, vm.uiState.value.measurementStartedAtElapsedMillis) - } - - @Test fun pendingOrFailedStopKeepsTimerUntilMeasurementActuallyEnds() { - clock(1_000) - vm.startMeasure() - val response = CompletableDeferred>() - coEvery { repository.stopStreaming() } coAnswers { response.await() } - vm.stopMeasure() - assertTrue(vm.uiState.value.isMeasuring) - assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis) - response.complete(DctResult.Failure(DctError.ProtocolError.ConnectionFailed("STOP failed"))) - assertTrue(vm.uiState.value.isMeasuring) - assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis) - coEvery { repository.stopStreaming() } returns DctResult.Success(Unit) - vm.stopMeasure() - assertFalse(vm.uiState.value.isMeasuring) - assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) - } - - @Test fun automaticRecoveryAndDebugValueResetKeepOriginalMeasurementStart() { - clock(1_000) - vm.startMeasure() - clock(300_000) - val recovery = repository.streamRecovery as MutableStateFlow - recovery.value = StreamRecoveryState.RECOVERING - vm.resetDebugValues() - assertTrue(vm.uiState.value.isMeasuring) - assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis) - recovery.value = StreamRecoveryState.RECEIVING - assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis) - } - - @Test fun disconnectAndNewProjectClearTimer() { - clock(1_000) - vm.startMeasure() - clock(11_000) - connection.value = DeviceConnectionState.DISCONNECTED - assertFalse(vm.uiState.value.isMeasuring) - assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) - vm.resetWorkspaceForNewProject() - assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt deleted file mode 100644 index 403575a..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.background.MeasurementExecutionState -import com.dct.app.data.communication.WifiDirectPhase -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.presentation.main.MainUiState -import com.dct.app.presentation.main.MeasurementRecordingState -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class MeasurementExecutionStateTest { - @Test fun recoveryKeepsRecordingAndItsStartTimeWhileShowingReceptionIsUnavailable() { - val notification = state(MainUiState(isMeasuring = true, isStreamRecovering = true, - recordingState = MeasurementRecordingState.RECORDING, recordingStartedAtMillis = 1234)) - assertTrue(notification.active) - assertTrue(notification.measuring) - assertTrue(notification.recording) - assertEquals(1234L, notification.startedAtMillis) - assertTrue(notification.title.contains("수신 복구 중")) - } - @Test fun apNegotiationAndTcpWaitKeepServiceButScanningDoesNot() { - fun ap(phase: com.dct.app.data.communication.WifiApPhase, present: Boolean = false) = - MeasurementExecutionState.from(MainUiState(), DeviceConnectionState.DISCONNECTED, false, - WifiDirectPhase.IDLE, false, phase, present) - assertFalse(ap(com.dct.app.data.communication.WifiApPhase.SCANNING).active) - assertTrue(ap(com.dct.app.data.communication.WifiApPhase.CONNECTING).active) - val waiting = ap(com.dct.app.data.communication.WifiApPhase.CONNECTED, true) - assertTrue(waiting.active); assertTrue(waiting.title.contains("Wi-Fi AP")) - assertFalse(ap(com.dct.app.data.communication.WifiApPhase.ERROR).active) - } - @Test fun notificationOmitsFrequencyWithoutChangingMeasurementSettingsOrProgress() { - for (frequency in listOf(30, 60, 120)) { - val ui = MainUiState(isMeasuring = true, measurementFrequencyHz = frequency, - projectFileName = "Test.dpc", connectedDeviceName = "Device", packetCount = 100, - recordingSampleCount = 42, recordingState = MeasurementRecordingState.RECORDING) - val notification = state(ui) - assertEquals("Test.dpc · Device · 수신 100건 · 기록 42건", notification.detail) - assertFalse(notification.title.contains("Hz")) - assertEquals(frequency, ui.measurementFrequencyHz) - assertTrue(notification.measuring) - assertTrue(notification.recording) - } - } - - private fun state(ui: MainUiState = MainUiState(), connection: DeviceConnectionState = DeviceConnectionState.DISCONNECTED, - socket: Boolean = false, phase: WifiDirectPhase = WifiDirectPhase.IDLE, group: Boolean = false) = - MeasurementExecutionState.from(ui, connection, socket, phase, group) - - @Test fun idleAndSearchDoNotKeepForegroundServiceRunning() { - assertFalse(state().active) - assertFalse(state(phase = WifiDirectPhase.DISCOVERING).active) - } - - @Test fun negotiationSocketAndGroupOwnerWaitingAreActiveBeforeHandshake() { - assertTrue(state(phase = WifiDirectPhase.CONNECTING).active) - assertTrue(state(connection = DeviceConnectionState.HANDSHAKING).active) - assertTrue(state(socket = true).active) - val waiting = state(group = true, phase = WifiDirectPhase.CONNECTED) - assertTrue(waiting.active) - assertTrue(waiting.title.contains("TCP 대기")) - assertFalse(waiting.measuring) - } - - @Test fun measurementAndRecordingRemainSeparateAndIncludeProgress() { - val ui = MainUiState(isMeasuring = true, projectFileName = "Test.dpc", connectedDeviceName = "Device", - packetCount = 100, recordingSampleCount = 42, recordingStartedAtMillis = 1234, - recordingState = MeasurementRecordingState.RECORDING) - val recording = state(ui) - assertTrue(recording.active) - assertTrue(recording.measuring) - assertTrue(recording.recording) - assertTrue(recording.title.contains("계측 및 기록")) - assertEquals(1234L, recording.startedAtMillis) - assertTrue(recording.detail.contains("수신 100건")) - assertTrue(recording.detail.contains("기록 42건")) - val measuringOnly = state(ui.copy(recordingState = MeasurementRecordingState.IDLE, recordingStartedAtMillis = null)) - assertTrue(measuringOnly.title.contains("기록 안 함")) - assertFalse(measuringOnly.recording) - } - - @Test fun stopMeasurementKeepsConnectionAndServiceButDisconnectEndsIt() { - val waiting = state(connection = DeviceConnectionState.READY) - assertTrue(waiting.active) - assertFalse(waiting.measuring) - assertTrue(waiting.title.contains("계측 대기")) - assertFalse(state(connection = DeviceConnectionState.ERROR).active) - assertTrue(state(MainUiState(recordingState = MeasurementRecordingState.STOPPING)).active) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt deleted file mode 100644 index 797cd60..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.logger.MeasurementLogPaths -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import java.io.File - -class MeasurementLogPathsTest { - private val base = File(System.getProperty("java.io.tmpdir"), "DctWork").absoluteFile - @Test fun pcSlashDataIsResolvedUnderAndroidWorkFolder() { - assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("/Data", base.path, true, base)) - assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("/Data", base.path, false, base)) - assertEquals(File(base, "Data/logs"), MeasurementLogPaths.resolve("\\Data\\logs", base.path, true, base)) - } - @Test fun emptyBaseAndPathUseFallback() { - assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("", "", false, base)) - assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("/Data", "", true, base)) - } - @Test fun actualAbsoluteDirectoryIsNotRebasedAndRelativeCannotEscape() { - val absolute = File(base.parentFile, "ExplicitLogs") - assertEquals(absolute, MeasurementLogPaths.resolve(absolute.path, base.path, false, base)) - assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("../../outside", base.path, true, base)) - } - @Test fun uncPcDirectoryFallsBackToLocalData() { - assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("\\\\PC\\Logs", "\\\\PC\\Project", false, base)) - } - @Test fun oldSafDisplayPathNeverBecomesARealFilesystemPath() { - assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("/tree/primary:Documents/Logs", base.path, false, base)) - assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("content://com.android.externalstorage.documents/tree/primary%3ADocuments", base.path, false, base)) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt deleted file mode 100644 index 1da7e71..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.logger.CsvMeasurementLogger -import com.dct.app.data.logger.Mf4MeasurementLogger -import com.dct.app.data.logger.PcCsvColumn -import com.dct.app.data.logger.PcMeasurementNames -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.io.RandomAccessFile - -@DisplayName("MeasurementLogger - CSV 및 ASAM MF4 실시간 계측 로거 단위 테스트") -class MeasurementLoggerTest { - - @Test - fun pcProjectNameAndSecondPrecisionAreUsedForMeasurementFiles() { - val stamp = PcMeasurementNames.timestamp(java.util.Date(0L)) - assertEquals("TEST", PcMeasurementNames.projectName("TEST.dpc")) - assertEquals("test", PcMeasurementNames.projectName("test.DPC")) - assertEquals("PROJECT", PcMeasurementNames.projectName("")) - assertTrue(stamp.matches(Regex("\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}"))) - assertEquals("TEST_${stamp}_mdb.mf4", PcMeasurementNames.fileName("TEST", stamp, "mdb", "mf4")) - } - - @Test - fun pcCsvHeaderKeepsIdentityPrecisionAndMissingValues(@TempDir tempDir: File) { - val file = CsvMeasurementLogger( - tempDir, - listOf("EngineSpeed" to "rpm", "Coolant" to "C"), - "TEST_2026-09-28_14-30-00_mdb.csv", - listOf(PcCsvColumn("12", "[0xA3-12]", 0), PcCsvColumn("13", "[0xA3-13]", 2)) - ).apply { - start() - logSample(0.1, listOf(1500.0, null)) - logSample(0.2, listOf(1512.0, 87.125)) - stop() - }.outputFile!! - val lines = file.readLines() - assertEquals("File: ${file.name}", lines[0]) - assertTrue(lines[1].startsWith("Date: ")) - assertTrue(lines[3].contains("12,")) - assertTrue(lines[4].contains("[0xA3-12]")) - assertTrue(lines[6].startsWith(" Time, Count,")) - assertTrue(lines[7].matches(Regex("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}, 0,1500,No value"))) - assertTrue(lines[8].endsWith(",1512,87.13")) - } - - @Test - fun a2lMissingMeasurementUsesPcNcMarker(@TempDir tempDir: File) { - val file = CsvMeasurementLogger(tempDir, listOf("Measurement" to "V"), "A2L_measurement.csv", - listOf(PcCsvColumn("0", "[0x1234]")), "NC").apply { - start() - logSample(0.1, listOf(null)) - stop() - }.outputFile!! - assertTrue(file.readLines().last().endsWith(",NC")) - } - - @Test - fun missingMf4ChannelIsNotWrittenAsMeasuredZero(@TempDir tempDir: File) { - val file = Mf4MeasurementLogger(tempDir, listOf("Signal" to "V"), "missing.mf4").apply { - start() - logSample(0.1, listOf(null)) - stop() - }.outputFile!! - RandomAccessFile(file, "r").use { raf -> - raf.seek(file.length() - 8) - val data = ByteArray(8).also(raf::readFully) - assertTrue(java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).double.isNaN()) - } - } - - @Test - @DisplayName("CsvMeasurementLogger가 정상적으로 CSV 헤더 및 시계열 물리값 레코드를 생성해야 한다") - fun testCsvMeasurementLogger(@TempDir tempDir: File) { - val headers = listOf("EngineSpeed" to "RPM", "VehicleSpeed" to "km/h") - val logger = CsvMeasurementLogger(tempDir, headers) - - val createdFile = logger.start() - assertNotNull(createdFile) - assertTrue(createdFile!!.exists()) - - // 3개 샘플 기록 - logger.logSample(0.033, listOf(1500.0, 60.5)) - logger.logSample(0.066, listOf(1550.0, 61.2)) - logger.logSample(0.099, listOf(1600.0, 62.0)) - logger.stop() - - val lines = createdFile.readLines() - assertTrue(lines.isNotEmpty()) - assertEquals("Time(s),EngineSpeed [RPM],VehicleSpeed [km/h]", lines[0]) - assertTrue(lines[1].startsWith("0.0330,1500.0000,60.5000")) - assertTrue(lines[2].startsWith("0.0660,1550.0000,61.2000")) - assertTrue(lines[3].startsWith("0.0990,1600.0000,62.0000")) - } - - @Test - @DisplayName("Mf4MeasurementLogger가 ASAM MDF v4.10 전체 계층 블록(HD->DG->CG->CN 체인->TX->DT) 및 cycle_count를 정확히 기록해야 한다") - fun testMf4MeasurementLogger(@TempDir tempDir: File) { - val headers = listOf("EngineSpeed" to "RPM", "CoolantTemp" to "'C") - val logger = Mf4MeasurementLogger(tempDir, headers) - - val createdFile = logger.start() - assertNotNull(createdFile) - assertTrue(createdFile!!.exists()) - - // 2개 샘플 기록 - logger.logSample(0.033, listOf(2000.0, 85.0)) - logger.logSample(0.066, listOf(2050.0, 85.5)) - logger.stop() - - // 바이너리 파일 크기 및 ASAM MDF 4.10 블록 계층 전수 검증 - assertTrue(createdFile.length() > 296) - RandomAccessFile(createdFile, "r").use { raf -> - val buf4 = ByteArray(4) - val buf8 = ByteArray(8) - - // 1. ID Block (0..63) - val idMagic = ByteArray(8) - raf.seek(0) - raf.readFully(idMagic) - assertEquals("MDF ", String(idMagic, Charsets.US_ASCII)) - - val versionMagic = ByteArray(8) - raf.readFully(versionMagic) - assertEquals("4.10 ", String(versionMagic, Charsets.US_ASCII)) - - // 2. HD Block (64..167, 104B) - raf.seek(64) - raf.readFully(buf4) - assertEquals("##HD", String(buf4, Charsets.US_ASCII)) - raf.seek(64 + 8) - assertEquals(104L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // HD length - assertEquals(6L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // link count - val dgOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // hd_dg_first - assertEquals(168L, dgOffset) - - // 3. DG Block (168..231, 64B) - raf.seek(dgOffset) - raf.readFully(buf4) - assertEquals("##DG", String(buf4, Charsets.US_ASCII)) - raf.seek(dgOffset + 8) - assertEquals(64L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // DG length - assertEquals(4L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // link count - raf.readFully(buf8) // dg_next (0L) - val cgOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // dg_cg_first - assertEquals(232L, cgOffset) - val dtOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // dg_data - assertTrue(dtOffset > cgOffset) - - // ASAM 4.10 CG: 24-byte common header, six links, 32-byte data. - raf.seek(cgOffset) - raf.readFully(buf4) - assertEquals("##CG", String(buf4, Charsets.US_ASCII)) - raf.seek(cgOffset + 8) - assertEquals(104L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // CG length - assertEquals(6L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) - raf.readFully(buf8) // next CG - val firstCnOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cg_cn_first - assertEquals(336L, firstCnOffset) - raf.seek(cgOffset + 72) - val recordId = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // record_id - assertEquals(0L, recordId) - - // cycle_count follows all six links and record_id: CG + 80. - val cycleCount = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cycle_count - assertEquals(2L, cycleCount, "stop() 후 cycle_count는 기록된 레코드 수(2)와 일치해야 합니다.") - - // 5. CN Block 체인 검증 (첫 번째 채널: Time 마스터 채널) - raf.seek(firstCnOffset) - raf.readFully(buf4) - assertEquals("##CN", String(buf4, Charsets.US_ASCII)) - raf.seek(firstCnOffset + 8) - assertEquals(160L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // CN length - assertEquals(8L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // link count - val secondCnOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cn_next - assertTrue(secondCnOffset > firstCnOffset) - raf.readFully(buf8) // cn_composition (0L) - val timeNameTxOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cn_tx_name - assertTrue(timeNameTxOffset > 0) - - // Name TXBlock 확인 ("Time") - raf.seek(timeNameTxOffset) - raf.readFully(buf4) - assertEquals("##TX", String(buf4, Charsets.US_ASCII)) - raf.seek(timeNameTxOffset + 24) - val nameBytes = ByteArray(5) - raf.readFully(nameBytes) - assertEquals("Time\u0000", String(nameBytes, Charsets.UTF_8)) - - // Time 채널의 cn_type (마스터 = 2), cn_sync_type (시간 = 1), cn_data_type (Float LE = 4) - raf.seek(firstCnOffset + 88) - assertEquals(2.toByte(), raf.readByte()) // cn_type - assertEquals(1.toByte(), raf.readByte()) // cn_sync_type - assertEquals(4.toByte(), raf.readByte()) // cn_data_type - - // 6. DT Block 검증 - raf.seek(dtOffset) - raf.readFully(buf4) - assertEquals("##DT", String(buf4, Charsets.US_ASCII)) - raf.seek(dtOffset + 8) - val expectedDtLength = 24L + (2L * (8 + 2 * 8)) // 24 header + 2 records * 24 bytes = 72L - val actualDtLength = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long - assertEquals(expectedDtLength, actualDtLength, "DT 블록 길이는 헤더 24B + 레코드 크기 합산과 일치해야 합니다.") - } - } - - @Test - @DisplayName("표준 파일명 규칙(프로젝트명_날짜_시간_소스타입.csv/mf4)으로 로거가 정상 생성되어야 한다") - fun testStandardNamingFormat(@TempDir tempDir: File) { - val headers = listOf("EngSpd" to "rpm", "Torque" to "Nm") - val customCsvName = "241213_SW_EU_TEST01_2026-07-02_08-39-15_dbc.csv" - val customMf4Name = "241213_SW_EU_TEST01_2026-07-02_08-39-15_mdb.mf4" - - val csvLogger = CsvMeasurementLogger(tempDir, headers, customCsvName) - val csvFile = csvLogger.start() - assertNotNull(csvFile) - assertEquals("241213_SW_EU_TEST01_2026-07-02_08-39-15_dbc.csv", csvFile!!.name) - csvLogger.stop() - - val mf4Logger = Mf4MeasurementLogger(tempDir, headers, customMf4Name) - val mf4File = mf4Logger.start() - assertNotNull(mf4File) - assertEquals("241213_SW_EU_TEST01_2026-07-02_08-39-15_mdb.mf4", mf4File!!.name) - mf4Logger.stop() - - // A2L / 통합 계측: _measurement - val a2lCsvName = "SP3_20260622_2026-07-02_13-40-46_measurement.csv" - val a2lMf4Name = "SP3_20260622_2026-07-02_13-40-17_measurement.mf4" - val a2lCsvLogger = CsvMeasurementLogger(tempDir, headers, a2lCsvName) - val a2lCsvFile = a2lCsvLogger.start() - assertNotNull(a2lCsvFile) - assertEquals("SP3_20260622_2026-07-02_13-40-46_measurement.csv", a2lCsvFile!!.name) - a2lCsvLogger.stop() - - val a2lMf4Logger = Mf4MeasurementLogger(tempDir, headers, a2lMf4Name) - val a2lMf4File = a2lMf4Logger.start() - assertNotNull(a2lMf4File) - assertEquals("SP3_20260622_2026-07-02_13-40-17_measurement.mf4", a2lMf4File!!.name) - a2lMf4Logger.stop() - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementRecordingConcurrencyTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementRecordingConcurrencyTest.kt deleted file mode 100644 index a1d95ec..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementRecordingConcurrencyTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.logger.CsvMeasurementLogger -import com.dct.app.data.logger.Mf4MeasurementLogger -import com.dct.app.data.logger.PcCsvColumn -import com.dct.app.data.logger.PcMf4Channel -import com.dct.app.domain.model.project.DpcProject -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.presentation.main.MainUiState -import com.dct.app.presentation.main.MainViewModel -import com.dct.app.presentation.main.MeasurementRecordingState -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import java.util.concurrent.atomic.AtomicBoolean - -@Suppress("UNCHECKED_CAST") -class MeasurementRecordingConcurrencyTest { - @TempDir lateinit var directory: File - private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } - - @Test fun stopAckAndDisconnectCannotSplitAnInFlightCsvMf4Sample() { - // Both automatic termination and explicit recording-stop share the actual close path. - for (termination in listOf("finishMeasurementSession", "closeMeasurementLoggers")) { - val fixture = MainViewModelTest().also { it.setUp() } - val vm = field(fixture, "viewModel").get(fixture) as MainViewModel - val betweenFormats = CountDownLatch(1) - val releaseSample = CountDownLatch(1) - val stopping = CountDownLatch(1) - val armed = AtomicBoolean(false) - val executor = Executors.newFixedThreadPool(2) - val blockedOnce = AtomicBoolean(false) - val headers = object : AbstractList>() { - override val size = 1 - override fun get(index: Int): Pair { - if (armed.get() && blockedOnce.compareAndSet(false, true)) { - betweenFormats.countDown() - check(releaseSample.await(10, TimeUnit.SECONDS)) - } - return "Incar_Snr" to "C" - } - } - val csv = CsvMeasurementLogger(directory, listOf("Incar_Snr" to "C"), "$termination.csv", listOf(PcCsvColumn())) - val mf4 = Mf4MeasurementLogger(directory, headers, "$termination.mf4", - listOf(PcMf4Channel("Incar_Snr", "C", bitCount = 32, physicalInput = true))) - try { - vm.setCurrentProject(DpcProject(writeCsv = false, writeMf4 = false)) - assertNotNull(csv.start()) - assertNotNull(mf4.start(0L)) - (field(vm, "activeCsvLoggers").get(vm) as MutableList).add(csv) - (field(vm, "activeMf4Loggers").get(vm) as MutableList).add(mf4) - val ui = field(vm, "_uiState").get(vm) as MutableStateFlow - ui.value = ui.value.copy(isMeasuring = true, recordingState = MeasurementRecordingState.RECORDING) - val packet = DctPacket(0x02, ByteArray(18).also { - val buffer = ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) - buffer.position(2) - buffer.putFloat(15f).putFloat(2f).putFloat(3f).putFloat(4f) - }) - val process = MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, Double::class.javaPrimitiveType).also { it.isAccessible = true } - armed.set(true) - val writing = executor.submit { process.invoke(vm, packet, 0.5) } - assertTrue(betweenFormats.await(5, TimeUnit.SECONDS)) - val stop = executor.submit { - stopping.countDown() - MainViewModel::class.java.getDeclaredMethod(termination).also { it.isAccessible = true }.invoke(vm) - } - assertTrue(stopping.await(5, TimeUnit.SECONDS)) - assertThrows(TimeoutException::class.java) { stop.get(100, TimeUnit.MILLISECONDS) } - releaseSample.countDown() - writing.get(5, TimeUnit.SECONDS) - stop.get(5, TimeUnit.SECONDS) - assertEquals(1, csv.outputFile!!.readLines().drop(7).size) - val binary = ByteBuffer.wrap(mf4.outputFile!!.readBytes()).order(ByteOrder.LITTLE_ENDIAN) - assertEquals(1L, binary.getLong(232 + 80)) - assertEquals(1L, vm.uiState.value.recordingSampleCount) - // A late packet after close must not reopen or extend either file. - process.invoke(vm, packet, 1.0) - assertEquals(1, csv.outputFile!!.readLines().drop(7).size) - assertEquals(1L, vm.uiState.value.recordingSampleCount) - } finally { - releaseSample.countDown() - executor.shutdown() - executor.awaitTermination(10, TimeUnit.SECONDS) - csv.stop() - mf4.stop() - vm.scope.cancel() - fixture.tearDown() - } - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt deleted file mode 100644 index 65bb7f9..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt +++ /dev/null @@ -1,251 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.DctResult -import com.dct.app.data.communication.WifiSocketManager -import com.dct.app.data.logger.CsvMeasurementLogger -import com.dct.app.domain.model.project.DpcProject -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.domain.repository.PacketRepository -import com.dct.app.presentation.main.MainViewModel -import com.dct.app.presentation.main.MeasurementRecordingState -import io.mockk.* -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.file.Path -import java.nio.ByteBuffer -import java.nio.ByteOrder - -@Suppress("UNCHECKED_CAST") -class MeasurementRestartRegressionTest { - @TempDir lateinit var directory: Path - private lateinit var fixture: MainViewModelTest - private lateinit var vm: MainViewModel - private lateinit var repository: PacketRepository - private lateinit var packets: MutableSharedFlow - private lateinit var state: MutableStateFlow - private lateinit var loggers: MutableList - private lateinit var socket: WifiSocketManager - private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } - - @BeforeEach fun setUp() { - fixture = MainViewModelTest().also { it.setUp() } - vm = field(fixture, "viewModel").get(fixture) as MainViewModel - repository = field(fixture, "packetRepository").get(fixture) as PacketRepository - packets = repository.packetStream as MutableSharedFlow - state = field(fixture, "connectionState").get(fixture) as MutableStateFlow - loggers = field(vm, "activeCsvLoggers").get(vm) as MutableList - socket = mockk(relaxed = true) - field(vm, "wifiSocketManager").set(vm, socket) - vm.setCurrentProject(DpcProject(measuredFilesFolder = directory.toString(), writeCsv = false, writeMf4 = false)) - state.value = DeviceConnectionState.READY - } - - @AfterEach fun cleanUp() { - vm.scope.cancel() - fixture.tearDown() - } - - @Test fun receptionRecoveryPreservesMeasurementRecordingAndExistingLoggerUntilDataReturns() { - vm.startMeasure() - val logger = CsvMeasurementLogger(directory.toFile(), listOf("Incar_Snr" to "")) - loggers.add(logger) - val ui = field(vm, "_uiState").get(vm) as MutableStateFlow - ui.value = ui.value.copy(recordingState = MeasurementRecordingState.RECORDING, - recordingStartedAtMillis = 1234, recordingSampleCount = 10) - val recovery = repository.streamRecovery as MutableStateFlow - val before = ui.value.packetCount - recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECOVERING - assertTrue(ui.value.isMeasuring) - assertTrue(ui.value.isStreamRecovering) - assertEquals(MeasurementRecordingState.RECORDING, ui.value.recordingState) - assertEquals(1234L, ui.value.recordingStartedAtMillis) - assertEquals(10L, ui.value.recordingSampleCount) - assertSame(logger, loggers.single()) - recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECEIVING - packets.tryEmit(DctPacket(0x02, ByteArray(18).also { - ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).putFloat(10f).putFloat(2f).putFloat(3f).putFloat(4f) - })) - assertFalse(ui.value.isStreamRecovering) - assertEquals(before + 1, ui.value.packetCount) - assertEquals(11L, ui.value.recordingSampleCount) - assertEquals(1234L, ui.value.recordingStartedAtMillis) - assertSame(logger, loggers.single()) - vm.stopMeasure() - } - - @Test fun clearingLoggerListDuringPacketProcessingDoesNotKillSubscription() { - vm.startMeasure() - val subscribers = packets.subscriptionCount.value - val headers = object : AbstractList>() { - override val size = 1 - override fun get(index: Int): Pair { - loggers.clear() // Force STOP's list mutation between iterator steps. - return "test" to "" - } - } - loggers.add(CsvMeasurementLogger(directory.toFile(), headers)) - loggers.add(CsvMeasurementLogger(directory.toFile(), emptyList())) - val before = vm.uiState.value.packetCount - // Unknown commands are intentionally not counted as measurement data. - val telemetry = DctPacket(0x02, ByteArray(18)) - packets.tryEmit(telemetry) - packets.tryEmit(telemetry) - assertEquals(subscribers, packets.subscriptionCount.value) - assertEquals(before + 2, vm.uiState.value.packetCount) - } - - @Test fun measurementStartDoesNotAutomaticallyStartFileRecording() { - vm.setCurrentProject( - DpcProject( - measuredFilesFolder = directory.toString(), - writeCsv = true, - writeMf4 = true - ) - ) - - vm.startMeasure() - - assertTrue(vm.uiState.value.isMeasuring) - assertEquals(MeasurementRecordingState.IDLE, vm.uiState.value.recordingState) - assertTrue(loggers.isEmpty()) - val mf4Loggers = field(vm, "activeMf4Loggers").get(vm) as List<*> - assertTrue(mf4Loggers.isEmpty()) - } - - @Test fun malformedStreamIsDiagnosticOnlyAndDoesNotChangeToastStatus() { - vm.startMeasure() - val before = vm.uiState.value.statusMessage - // A3 requires a type byte and a complete payload structure. This is - // deliberately malformed and must be dropped without a user Toast. - packets.tryEmit(DctPacket(0xA3.toByte(), byteArrayOf(0x09))) - assertEquals(before, vm.uiState.value.statusMessage) - } - - @Test fun restartTimestampGraceAcceptsSmallClockSkewButDropsOlderPackets() { - vm.startMeasure() - val requestMillis = 1_000L - field(vm, "measurementRequestMillis").setLong(vm, requestMillis) - val before = vm.uiState.value.packetCount - - // A packet older than the 200 ms grace window belongs to the previous - // measurement session and must not update the dashboard. - packets.tryEmit(DctPacket(0x02, ByteArray(18), receivedAtMillis = requestMillis - 201L)) - assertEquals(before, vm.uiState.value.packetCount) - - // The exact boundary is intentionally accepted to tolerate the small - // wall-clock/monotonic-clock offset observed after STOP -> START. - packets.tryEmit(DctPacket(0x02, ByteArray(18), receivedAtMillis = requestMillis - 200L)) - assertEquals(before + 1, vm.uiState.value.packetCount) - } - - @Test fun packetProcessingExceptionDoesNotPreventStopStartOrReconnectReceiving() { - vm.chartRenderer.bindSignal("s", "Incar_Snr", com.dct.app.domain.model.chart.AxisPosition.LEFT_0, 0xFF0000L) - fun telemetry(value: Float) = DctPacket(0x02, ByteArray(18).also { - val buffer = ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) - buffer.position(2) - buffer.putFloat(value).putFloat(2f).putFloat(3f).putFloat(4f) - }) - vm.startMeasure() - val subscribers = packets.subscriptionCount.value - val badHeaders = object : AbstractList>() { - override val size = 1 - override fun get(index: Int): Pair { - loggers.clear() - throw IllegalStateException("injected packet failure") - } - } - loggers.add(CsvMeasurementLogger(directory.toFile(), badHeaders)) - packets.tryEmit(telemetry(10f)) - // Processing failures are diagnostic-only and must not surface as a - // global Toast/status message while the subscription stays alive. - assertFalse(vm.uiState.value.statusMessage.orEmpty().contains("수신 패킷 처리 실패")) - assertEquals(subscribers, packets.subscriptionCount.value) - val before = vm.uiState.value.packetCount - vm.stopMeasure() - vm.startMeasure() - packets.tryEmit(telemetry(20f)) - assertEquals(before + 1, vm.uiState.value.packetCount) - assertEquals(20.0, vm.liveTelemetryValues.value["Incar_Snr"]) - state.value = DeviceConnectionState.ERROR - state.value = DeviceConnectionState.DISCONNECTED - state.value = DeviceConnectionState.READY - vm.syncDeviceConfiguration() - vm.startMeasure() - packets.tryEmit(telemetry(30f)) - assertTrue(vm.uiState.value.isMeasuring) - assertEquals(before + 2, vm.uiState.value.packetCount) - assertEquals(30.0, vm.liveTelemetryValues.value["Incar_Snr"]) - val buffer = vm.chartRenderer.seriesListFlow.value.single().buffer - assertEquals(30.0, buffer.getValueAt(buffer.size - 1)) - } - - @Test fun startWaitsForStopCompletionAndDuplicateStartIsNotSent() { - vm.startMeasure() - val stopAck = CompletableDeferred>() - coEvery { repository.stopStreaming() } coAnswers { stopAck.await() } - vm.stopMeasure() - vm.startMeasure() - vm.startMeasure() - coVerify(exactly = 1) { repository.startStreaming(any()) } - stopAck.complete(DctResult.Success(Unit)) - coVerify(exactly = 2) { repository.startStreaming(any()) } - assertTrue(vm.uiState.value.isMeasuring) - } - - @Test fun pendingConfigurationMustFinishBeforeStartAndFailureBlocksStart() { - val configAck = CompletableDeferred>() - coEvery { repository.configureMeasurement(any()) } coAnswers { configAck.await() } - vm.syncDeviceConfiguration() - vm.startMeasure() - coVerify(exactly = 0) { repository.startStreaming(any()) } - configAck.complete(DctResult.Success(Unit)) - coVerify(exactly = 1) { repository.startStreaming(any()) } - vm.stopMeasure() - coEvery { repository.configureMeasurement(any()) } returns DctResult.Failure(com.dct.app.core.error.DctError.Unknown("NACK")) - vm.syncDeviceConfiguration() - vm.startMeasure() - coVerify(exactly = 1) { repository.startStreaming(any()) } - assertFalse(vm.uiState.value.isMeasuring) - } - - @Test fun disconnectInvalidatesDelayedStartAck() { - val startAck = CompletableDeferred>() - coEvery { repository.startStreaming(any()) } coAnswers { startAck.await() } - vm.startMeasure() - vm.disconnectCommunication() - startAck.complete(DctResult.Success(Unit)) - assertFalse(vm.uiState.value.isMeasuring) - coVerify(exactly = 1) { repository.disconnect() } - assertFalse(vm.uiState.value.isConnected) - } - - @Test fun reconnectWaitsForDisconnectAndOldConnectionCallbackIsIgnored() { - val callbacks = mutableListOf<(Boolean, String) -> Unit>() - every { socket.startDualMode(any(), any(), any(), capture(callbacks), any(), any()) } just Runs - coEvery { repository.connect(any()) } returns DctResult.Success(Unit) - vm.connectWifi("first", "127.0.0.1") - assertEquals(1, callbacks.size) - val disconnectAck = CompletableDeferred>() - coEvery { repository.disconnect() } coAnswers { disconnectAck.await() } - vm.disconnectCommunication() - vm.connectWifi("second", "127.0.0.1") - assertEquals(1, callbacks.size) - callbacks[0](false, "stale") - disconnectAck.complete(DctResult.Success(Unit)) - assertEquals(2, callbacks.size) - coVerify(exactly = 0) { repository.connect("stale") } - callbacks[1](false, "current") - coVerify(exactly = 1) { repository.connect("current") } - vm.startMeasure() - assertTrue(vm.uiState.value.isMeasuring) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt deleted file mode 100644 index 97239c3..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt +++ /dev/null @@ -1,396 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.DctResult -import com.dct.app.data.communication.WifiApEndpoint -import com.dct.app.data.communication.WifiSocketManager -import com.dct.app.data.repository.PacketRepositoryImpl -import com.dct.app.domain.model.protocol.DctMeasurementConfiguration -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.domain.repository.StreamRecoveryState -import io.mockk.* -import kotlinx.coroutines.* -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 MeasurementStreamRecoveryTest { - private class Fixture(val scope: TestScope, timeout: Long = 50, autoRecovery: Boolean = true) { - val wifi = mockk(relaxed = true) - val packets = MutableSharedFlow(extraBufferCapacity = 32) - val connected = MutableStateFlow(true) - var session = 7L - var owner = 42L - var nextSession = 8L - val sent = mutableListOf() - val times = mutableListOf() - val acks = mutableListOf() - val reconnects = mutableListOf>() - val events = mutableListOf() - var silent: Int? = null - var reject: Int? = null - var reconnectWorks = true - var emptyStop = false - var endpoint: WifiApEndpoint? = WifiApEndpoint("192.168.4.1", 5000) - var holdCommand: Int? = null - val gate = CompletableDeferred() - val dispatcher = UnconfinedTestDispatcher(scope.testScheduler) - init { - every { wifi.isConnected } returns connected - every { wifi.currentSessionId } answers { session } - every { wifi.recoveryGeneration } answers { owner } - every { wifi.recoveryEndpoint } answers { endpoint } - every { wifi.packetStream } returns packets - every { wifi.offerCommand(capture(acks)) } returns true - every { wifi.diagnosticEvent(any(), any()) } answers { events += secondArg() } - coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false; session = 0; owner++ } - coEvery { wifi.reconnectForRecovery(any(), any(), any()) } coAnswers { - reconnects += firstArg() to secondArg() - if (firstArg() != owner || !reconnectWorks) false else { - connected.value = false - yield() - session = nextSession++ - connected.value = true - true - } - } - coEvery { wifi.sendCommand(any()) } coAnswers { - val request = firstArg() - sent += request - times += scope.testScheduler.currentTime - val command = request.command.toInt() and 255 - if (holdCommand == command) gate.await() - if (silent != command && command !in listOf(0x0F, 0x17)) { - val data = when (command) { - 1 -> byteArrayOf(1, 65) - 3 -> ByteArray(6) - 9 -> byteArrayOf(request.payload[0], 1) - 0x08 -> byteArrayOf(0, 1) - 0x20 -> byteArrayOf(1, 1) - 5 -> byteArrayOf() - 0x21 -> if (emptyStop) byteArrayOf() else byteArrayOf(1) - else -> byteArrayOf(1) - } - if (reject == command) data[0] = 2 - packets.emit(DctPacket((command + 0x80).toByte(), data, transportSessionId = request.transportSessionId)) - } - true - } - } - val repo = PacketRepositoryImpl(wifiSocketManager = wifi, ioDispatcher = dispatcher, - responseTimeoutMs = timeout, heartbeatIntervalMs = 40, heartbeatTimeoutMs = 20, - heartbeatDispatcher = dispatcher, recoveryDispatcher = dispatcher, - monotonicTimeMillis = { scope.testScheduler.currentTime }, streamSilenceTimeoutMs = 300, - recoveryDataTimeoutMs = 200, scope = scope.backgroundScope, - initialReceiveAutoRecoveryEnabled = autoRecovery) - suspend fun open(measure: Boolean = true) { - assertTrue(repo.connect("") is DctResult.Success) - assertTrue(repo.configureMeasurement(DctMeasurementConfiguration(projectName = "SavedProject", vehicleCode = "Car")) is DctResult.Success) - if (measure) assertTrue(repo.startStreaming() is DctResult.Success) - sent.clear(); times.clear() - } - suspend fun stream(valid: Boolean = true, from: Long = session) = packets.emit( - DctPacket(0xA3.toByte(), if (valid) byteArrayOf(1, 0xA0.toByte(), 9) else byteArrayOf(), transportSessionId = from)) - suspend fun enable() = packets.emit(DctPacket(0x80.toByte(), byteArrayOf(1), transportSessionId = session)) - fun controls() = sent.map { it.command.toInt() and 255 }.filter { it != 5 } - suspend fun close() { repo.disconnect() } - } - - @Test fun repositoryDefaultLeavesAutoRecoveryOff() = runTest { - assertFalse(PacketRepositoryImpl(scope = backgroundScope).receiveAutoRecoveryEnabled.value) - } - - @Test fun disabledRecoveryIgnoresSilenceHeartbeatTimeoutAndUnexpectedEnableButStillAcksData() = runTest { - val f = Fixture(this, autoRecovery = false); f.open() - assertFalse(f.repo.receiveAutoRecoveryEnabled.value) - f.silent = 5 - f.enable() - advanceTimeBy(20_000); runCurrent() - assertTrue(f.controls().isEmpty()) - assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) - assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value) - f.stream(); runCurrent() - assertEquals(1, f.acks.size) - assertTrue(f.repo.isStreaming.value) - assertTrue(f.reconnects.isEmpty()) - f.close() - } - - @Test fun disabledRecoveryDoesNotReconnectOnTcpLoss() = runTest { - val f = Fixture(this); f.open() - f.repo.setReceiveAutoRecoveryEnabled(false) - f.session = 0; f.connected.value = false - advanceTimeBy(20_000); runCurrent() - assertTrue(f.reconnects.isEmpty()) - assertTrue(f.controls().isEmpty()) - assertEquals(DeviceConnectionState.ERROR, f.repo.connectionState.value) - assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) - f.close() - } - - @Test fun disablingDuringRecoveryCancelsPendingSetupAndAllLaterStarts() = runTest { - val f = Fixture(this); f.open(); f.holdCommand = 3 - advanceTimeBy(301); runCurrent() - assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) - f.repo.setReceiveAutoRecoveryEnabled(false) - val count = f.controls().size - f.gate.complete(Unit) - advanceTimeBy(20_000); runCurrent() - assertEquals(count, f.controls().size) - assertFalse(f.controls().contains(0x20)) - assertTrue(f.reconnects.isEmpty()) - assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) - f.close() - } - - @Test fun enablingDuringStalledMeasurementRestoresRecoveryWithoutManualRestart() = runTest { - val f = Fixture(this, autoRecovery = false); f.open() - advanceTimeBy(1_000); runCurrent() - f.repo.setReceiveAutoRecoveryEnabled(true) - advanceTimeBy(421); runCurrent() - assertEquals(1, f.controls().count { it == 0x20 }) - f.stream(); advanceTimeBy(25); runCurrent() - assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) - f.close() - } - - @Test fun disablingAfterStartKeepsNaturallyResumedTelemetryVisibleWithoutRetrying() = runTest { - val f = Fixture(this); f.open() - advanceTimeBy(621); runCurrent() - assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) - assertFalse(f.repo.isStreaming.value) - f.repo.setReceiveAutoRecoveryEnabled(false) - val count = f.controls().size - f.stream(); runCurrent() - assertTrue(f.repo.isStreaming.value) - assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) - advanceTimeBy(2_000); runCurrent() - assertEquals(count, f.controls().size) - assertEquals(1, f.acks.size) - f.close() - } - - @Test fun togglingRecoveryDuringHealthyMeasurementDoesNotRestartPlayback() = runTest { - val f = Fixture(this); f.open() - f.repo.setReceiveAutoRecoveryEnabled(false) - f.stream() - f.repo.setReceiveAutoRecoveryEnabled(true) - repeat(8) { advanceTimeBy(150); f.stream(); runCurrent() } - assertTrue(f.controls().isEmpty()) - assertEquals(9, f.acks.size) - f.close() - } - - @Test fun continuousValidDataNeverTriggersRecovery() = runTest { - val f = Fixture(this); f.open() - repeat(20) { advanceTimeBy(150); f.stream(); runCurrent() } - assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) - assertTrue(f.controls().isEmpty()) - assertEquals(20, f.acks.size) - f.close() - } - - @Test fun heartbeatAndStartAckCannotCompleteRecoveryButRealDataCan() = runTest { - val f = Fixture(this); f.open() - advanceTimeBy(299); runCurrent() - assertTrue(f.controls().isEmpty()) - advanceTimeBy(322); runCurrent() // 300 silence + 200 version gap + 120 DID setup. - assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) - assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value) - assertFalse(f.repo.isStreaming.value) - val controls = f.controls().size - assertTrue(f.repo.sendCommand(com.dct.app.data.protocol.DctProtocolRequests.hardwareId()) is DctResult.Failure) - assertEquals(controls, f.controls().size, "User commands must not interleave recovery setup") - assertTrue(f.sent.count { it.command == 5.toByte() } > 1) - assertTrue(f.controls().containsAll(listOf(0x21, 0, 1, 3, 4, 6, 8, 0x40, 0x11, 0x17, 9, 0x12, 0x20))) - val db = f.sent.single { it.command == 8.toByte() } - assertEquals("SavedProject", String(db.payload.copyOfRange(2, db.payload.size))) - advanceTimeBy(50); runCurrent() - assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) - f.stream(); advanceTimeBy(25); runCurrent() - assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) - assertTrue(f.repo.isStreaming.value) - verify { f.wifi.diagnosticEvent(7, match { it.startsWith("STREAM_RECOVERY_COMPLETE") }) } - f.close() - } - - @Test fun duplicateEnableDuringHealthyStreamingDoesNotResetTheDevice() = runTest { - val f = Fixture(this); f.open() - f.enable(); f.enable(); f.enable() - repeat(8) { advanceTimeBy(125); f.stream() } - runCurrent() - assertTrue(f.controls().isEmpty()) - assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) - assertEquals(8, f.acks.size) - f.close() - } - - @Test fun unsolicitedEnableWithNoDataRestoresSettingsAndStartsOnlyOnce() = runTest { - val f = Fixture(this); f.open() - f.enable(); f.enable() - advanceTimeBy(1321); runCurrent() - assertEquals(1, f.controls().count { it == 0x20 }) - assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) - f.stream(); advanceTimeBy(25); runCurrent() - assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) - f.close() - } - - @ParameterizedTest @ValueSource(ints = [1, 9, 0x20]) - fun missingSetupResponseRetriesOnANewSessionWithoutEndingMeasurement(command: Int) = runTest { - val f = Fixture(this); f.open(); f.silent = command - var budget = 0L - while (f.events.none { it.startsWith("STREAM_RECOVERY_FAILED") } && budget < 20_000) { - advanceTimeBy(25); runCurrent(); budget += 25 - } - assertTrue(budget < 20_000) - assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value) - assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) - f.silent = null - budget = 0 - while (f.sent.none { it.command == 0x20.toByte() && it.transportSessionId == 8L } && budget < 5_000) { - advanceTimeBy(25); runCurrent(); budget += 25 - } - assertTrue(budget < 5_000) - assertEquals(listOf(42L to 7L), f.reconnects) - f.stream(from = 7) // Late bytes from the failed socket cannot complete recovery. - runCurrent() - assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) - f.stream(); advanceTimeBy(25); runCurrent() - assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) - assertTrue(f.sent.filter { it.transportSessionId != 7L }.all { it.transportSessionId == 8L }) - f.close() - } - - @Test fun startAckWithNoDataRetriesInsteadOfClaimingSuccess() = runTest { - val f = Fixture(this); f.open() - advanceTimeBy(2825); runCurrent() - assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) - coVerify(exactly = 1) { f.wifi.reconnectForRecovery(42, 7, any()) } - advanceTimeBy(320); runCurrent() - f.stream(); advanceTimeBy(25); runCurrent() - assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) - f.close() - } - - @Test fun eofImmediatelyRecoversWhileKeepingMeasurementIntent() = runTest { - val f = Fixture(this); f.open() - f.session = 0; f.connected.value = false; runCurrent() - assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value) - advanceTimeBy(321); runCurrent() - coVerify(exactly = 1) { f.wifi.reconnectForRecovery(42, 0, any()) } - f.stream(); advanceTimeBy(25); runCurrent() - assertTrue(f.repo.isStreaming.value) - assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) - f.close() - } - - @Test fun malformedDataDoesNotRenewTheSilenceBudget() = runTest { - val f = Fixture(this); f.open() - repeat(3) { advanceTimeBy(100); f.stream(valid = false) } - runCurrent() - assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) - assertTrue(f.acks.all { it.payload.single() == 0.toByte() }) - f.close() - } - - @Test fun explicitStopDuringRecoveryCancelsAllLaterStartsAndRetries() = runTest { - val f = Fixture(this); f.open() - advanceTimeBy(301); runCurrent() - assertTrue(f.repo.stopStreaming() is DctResult.Success) - val commands = f.controls().size - advanceTimeBy(20_000); runCurrent() - assertEquals(commands, f.controls().size) - assertFalse(f.controls().contains(0x20)) - assertEquals(DeviceConnectionState.READY, f.repo.connectionState.value) - assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) - f.close() - } - - @Test fun explicitStopAlsoWorksWhileTcpIsUnavailable() = runTest { - val f = Fixture(this); f.open(); f.reconnectWorks = false - f.session = 0; f.connected.value = false; runCurrent() - assertTrue(f.repo.stopStreaming() is DctResult.Success) - advanceTimeBy(20_000); runCurrent() - assertEquals(DeviceConnectionState.DISCONNECTED, f.repo.connectionState.value) - assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) - assertFalse(f.repo.isStreaming.value) - coVerify(exactly = 1) { f.wifi.reconnectForRecovery(any(), any(), any()) } - f.close() - } - - @Test fun disconnectDuringRecoveryNeverReopensTheTransport() = runTest { - val f = Fixture(this); f.open() - advanceTimeBy(301); runCurrent() - f.close() - val commands = f.sent.size - advanceTimeBy(20_000); runCurrent() - assertEquals(commands, f.sent.size) - assertEquals(DeviceConnectionState.DISCONNECTED, f.repo.connectionState.value) - coVerify(exactly = 0) { f.wifi.reconnectForRecovery(any(), any(), any()) } - } - - @Test fun retryBackoffDoesNotSpinWhenDeviceIsUnavailable() = runTest { - val f = Fixture(this); f.open(); f.reconnectWorks = false - f.session = 0; f.connected.value = false; runCurrent() - advanceTimeBy(1999); runCurrent() - coVerify(exactly = 1) { f.wifi.reconnectForRecovery(any(), any(), any()) } - advanceTimeBy(1); runCurrent() - coVerify(exactly = 2) { f.wifi.reconnectForRecovery(any(), any(), any()) } - advanceTimeBy(3999); runCurrent() - coVerify(exactly = 2) { f.wifi.reconnectForRecovery(any(), any(), any()) } - advanceTimeBy(1); runCurrent() - coVerify(exactly = 3) { f.wifi.reconnectForRecovery(any(), any(), any()) } - f.close() - } - - @Test fun readinessAloneDoesNotStartMeasurementAutomatically() = runTest { - val f = Fixture(this); f.open(measure = false) - advanceTimeBy(20_000); runCurrent() - assertTrue(f.controls().isEmpty()) - f.enable(); advanceTimeBy(250); runCurrent() - assertEquals(listOf(1, 9, 9), f.controls()) - assertEquals(DeviceConnectionState.READY, f.repo.connectionState.value) - f.close() - } - - @Test fun lostStartAckDoesNotResetAlreadyResumedFreshTelemetry() = runTest { - val f = Fixture(this); f.open(); f.silent = 0x20 - advanceTimeBy(621); runCurrent() - repeat(160) { advanceTimeBy(100); f.stream(); runCurrent() } - assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) - assertTrue(f.repo.isStreaming.value) - assertTrue(f.reconnects.isEmpty()) - assertEquals(1, f.controls().count { it == 0x20 }) - f.close() - } - - @Test fun legacyEmptyStopAckIsAcceptedDuringRecovery() = runTest { - val f = Fixture(this); f.open(); f.emptyStop = true - advanceTimeBy(621); runCurrent() - assertEquals(1, f.controls().count { it == 0x20 }) - f.stream(); advanceTimeBy(25); runCurrent() - assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) - assertTrue(f.reconnects.isEmpty()) - f.close() - } - - @Test fun manualStopCancelsARecoveryCommandWaitingForAnotherRequest() = runTest { - val f = Fixture(this, timeout = 2000); f.open(); f.holdCommand = 3 - val blocking = async { f.repo.sendCommand(com.dct.app.data.protocol.DctProtocolRequests.hardwareId()) } - runCurrent(); advanceTimeBy(301); runCurrent() - val stop = async { f.repo.stopStreaming() } - runCurrent(); f.gate.complete(Unit) - assertTrue(blocking.await() is DctResult.Success) - assertTrue(stop.await() is DctResult.Success) - assertEquals(listOf(3, 0x21), f.controls()) - advanceTimeBy(2000); runCurrent() - assertFalse(f.controls().contains(0x20)) - f.close() - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/Mf4RecordingRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/Mf4RecordingRegressionTest.kt deleted file mode 100644 index ced4cd3..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/Mf4RecordingRegressionTest.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.logger.CsvMeasurementLogger -import com.dct.app.data.logger.Mf4MeasurementLogger -import com.dct.app.data.logger.PcCsvColumn -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -class Mf4RecordingRegressionTest { - @TempDir lateinit var directory: File - private val startedAt = 1_790_923_490_000L - private fun bytes(file: File) = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN) - private fun dt(buffer: ByteBuffer) = buffer.getLong(168 + 40).toInt() - private fun export(file: File) { - // Optional artifacts for an independent asammdf check, not an app dependency. - System.getenv("DCT_RECORDING_FIXTURES_DIR")?.let { path -> - File(path).apply { mkdirs() }.resolve(file.name).also { file.copyTo(it, overwrite = true) } - } - } - - @Test fun epochInputBecomesRelativeTimeWithoutChangingPhysicalValuesOrUnits() { - val headers = listOf("온도" to "°C", "Speed" to "km/h", "Missing" to "") - val logger = Mf4MeasurementLogger(directory, headers, "relative.mf4") - val file = logger.start(startedAt)!! - logger.logSample(startedAt / 1000.0 + 0.125, listOf(-12.5, 87.25, null)) - logger.logSample(startedAt / 1000.0 + 0.5, listOf(3.75, 88.0, Double.POSITIVE_INFINITY)) - logger.stop() - val data = bytes(file) - assertEquals(410, data.getShort(28).toInt()) - assertEquals(startedAt * 1_000_000, data.getLong(64 + 72)) - assertEquals(6L, data.getLong(232 + 16)) - assertEquals(2L, data.getLong(232 + 80)) - assertEquals(0.125, data.getDouble(dt(data) + 24), 0.000001) - assertEquals(0.5, data.getDouble(dt(data) + 24 + 32), 0.000001) - assertEquals(-12.5, data.getDouble(dt(data) + 32)) - assertTrue(data.getDouble(dt(data) + 48).isNaN()) - assertTrue(data.getDouble(dt(data) + 80).isNaN()) - export(file) - } - - @Test fun emptyMeasurementAndTimeOnlyFilesAreFinalized() { - for ((name, headers) in listOf("empty.mf4" to listOf("Signal" to "V"), - "time_only.mf4" to emptyList())) { - val logger = Mf4MeasurementLogger(directory, headers, name) - val file = logger.start(startedAt)!! - logger.stop() - logger.stop() // Idempotent close must not damage an empty recording. - val data = bytes(file) - assertEquals(0L, data.getLong(232 + 80)) - assertEquals(24L, data.getLong(dt(data) + 8)) - assertEquals(file.length(), dt(data) + 24L) - export(file) - } - } - - @Test fun wideDbcRecordingKeepsAllChannelsAndMissingTail() { - val headers = (0 until 4939).map { "DBC_$it" to if (it % 2 == 0) "V" else "" } - val logger = Mf4MeasurementLogger(directory, headers, "wide.mf4") - val file = logger.start(startedAt)!! - logger.logSample(startedAt / 1000.0 + 0.25, listOf(123.5)) - logger.stop() - val data = bytes(file) - assertEquals(1L, data.getLong(232 + 80)) - assertEquals(24L + 4940 * 8, data.getLong(dt(data) + 8)) - assertTrue(data.getDouble(data.limit() - 8).isNaN()) - export(file) - } - - @Test fun loggerRestartUsesNewOriginAndInvalidTimeDoesNotAddASample() { - val logger = Mf4MeasurementLogger(directory, listOf("Signal" to "V"), "restart.mf4") - assertNotNull(logger.start(startedAt)) - logger.logSample(startedAt / 1000.0 + 1.0, listOf(1.0)) - logger.stop() - val file = logger.start(startedAt + 5000)!! - logger.logSample(Double.NaN, listOf(99.0)) - logger.logSample((startedAt + 5000) / 1000.0 + 0.25, listOf(2.0)) - logger.stop() - val data = bytes(file) - assertEquals(1L, data.getLong(232 + 80)) - assertEquals(0.25, data.getDouble(dt(data) + 24), 0.000001) - export(file) - } - - @Test fun pcCsvUsesPacketTimeRatherThanDiskWriteTimeAndMatchesMf4Rows() { - val headers = listOf("Signal" to "V") - val csv = CsvMeasurementLogger(directory, headers, "paired.csv", listOf(PcCsvColumn("0", "[0x1]")), - withMilliseconds = true) - val mf4 = Mf4MeasurementLogger(directory, headers, "paired.mf4") - val csvFile = csv.start()!! - val mf4File = mf4.start(startedAt)!! - for ((offset, value) in listOf(0.125 to 12.25, 0.5 to -3.5)) { - val packetTime = startedAt / 1000.0 + offset - csv.logSample(packetTime, listOf(value)) - mf4.logSample(packetTime, listOf(value)) - } - csv.stop() - mf4.stop() - val rows = csvFile.readLines().drop(7) - assertEquals(2, rows.size) - assertTrue(rows[0].startsWith(SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date(startedAt + 125)))) - assertTrue(rows[0].endsWith(", 0,12.250")) - assertTrue(rows[1].endsWith(", 1,-3.500")) - assertEquals(rows.size.toLong(), bytes(mf4File).getLong(232 + 80)) - export(csvFile) - export(mf4File) - } - - @Test fun queuedPacketsBeforeSharedOriginKeepTheirCsvTimeAcrossSources() { - // Reproduce HE1i: ingress .905/.906 precedes the common header origin .915. - val originMillis = startedAt + 915 - val originSec = originMillis / 1000.0 - for ((source, earlyMillis) in listOf("mdb" to 905, "dbc" to 906, "formula" to 905)) { - val headers = listOf("Signal" to "V") - val csv = CsvMeasurementLogger(directory, headers, "queued_$source.csv", - listOf(PcCsvColumn("0", "[0x1]")), withMilliseconds = true) - val mf4 = Mf4MeasurementLogger(directory, headers, "queued_$source.mf4") - val csvFile = csv.start()!! - val mf4File = mf4.start(originMillis)!! - val packetMillis = listOf(startedAt + earlyMillis, originMillis, originMillis + 125) - for ((index, millis) in packetMillis.withIndex()) { - val packetSec = millis / 1000.0 - csv.logSample(packetSec, listOf(index.toDouble())) - mf4.logSample(packetSec, listOf(index.toDouble())) - } - csv.stop() - mf4.stop() - val data = bytes(mf4File) - val rows = csvFile.readLines().drop(7) - assertEquals(originMillis * 1_000_000, data.getLong(64 + 72)) - assertEquals(3L, data.getLong(232 + 80)) - assertEquals(3, rows.size) - assertTrue(data.getDouble(dt(data) + 24) < 0.0, "Queued ingress must not be clamped to zero") - for ((index, millis) in packetMillis.withIndex()) { - val relativeSec = data.getDouble(dt(data) + 24 + index * 16) - assertEquals(millis / 1000.0 - originSec, relativeSec, 0.000001) - assertEquals(millis / 1000.0, originSec + relativeSec, 0.000001) - val expectedClock = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date(millis)) - assertTrue(rows[index].startsWith("$expectedClock, $index,")) - assertEquals(index.toDouble(), data.getDouble(dt(data) + 32 + index * 16)) - } - export(csvFile) - export(mf4File) - } - } - - @Test fun queuedPacketAcrossMidnightKeepsThePreviousDayIngressTime() { - val originMillis = 1_790_985_600_005L - val originSec = originMillis / 1000.0 - val logger = Mf4MeasurementLogger(directory, listOf("Signal" to "V"), "queued_midnight.mf4") - val file = logger.start(originMillis)!! - val packetMillis = originMillis - 10 - logger.logSample(packetMillis / 1000.0, listOf(7.0)) - logger.stop() - val data = bytes(file) - val relativeSec = data.getDouble(dt(data) + 24) - assertEquals(-0.01, relativeSec, 0.000001) - assertEquals(packetMillis / 1000.0, originSec + relativeSec, 0.000001) - assertEquals(1L, data.getLong(232 + 80)) - export(file) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt deleted file mode 100644 index 1d5e10d..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer -import com.dct.app.domain.model.chart.DownsamplingAlgorithms -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import kotlin.math.sin -import kotlin.system.measureTimeMillis - -@DisplayName("MinMaxDownsampler 픽셀 컬럼 다운샘플링 단위 테스트") -class MinMaxDownsamplerTest { - - @Test - @DisplayName("10,000개 데이터 포인트를 500픽셀로 압축 시 80% 이상의 축소율을 달성해야 한다") - fun testDataCompressionRatio() { - val totalPoints = 10_000 - val buffer = TimeSeriesRingBuffer(totalPoints) - for (i in 0 until totalPoints) { - val t = i * 0.001 - val v = sin(t * 10.0) * 100.0 - buffer.addSample(t, v) - } - - val screenWidthPx = 500 - val downsampled = DownsamplingAlgorithms.minMaxDownsample( - buffer = buffer, - firstIdx = 0, - lastIdx = totalPoints - 1, - tMin = 0.0, - tMax = 10.0, - screenWidthPx = screenWidthPx - ) - - // 화면 너비 500픽셀 기준 최대 2000개 이하로 압축 - assertTrue(downsampled.size <= screenWidthPx * 4, "압축된 포인트 개수가 4W 이하여야 함: ${downsampled.size}") - val reductionRatio = 1.0 - (downsampled.size.toDouble() / totalPoints.toDouble()) - assertTrue(reductionRatio >= 0.75, "압축률이 75% 이상이어야 합니다 (실제: ${reductionRatio * 100}%)") - } - - @Test - @DisplayName("중간에 발생한 급격한 임펄스 스파이크 Peak 및 Valley 극값이 100% 보존되어야 한다") - fun testImpulsePeakAndValleyPreservation() { - val totalPoints = 5_000 - val buffer = TimeSeriesRingBuffer(totalPoints) - - val spikeIndex = 2500 - val valleyIndex = 3500 - - for (i in 0 until totalPoints) { - val t = i * 0.01 - var v = 10.0 - if (i == spikeIndex) { - v = 9999.0 // 극단적 피크 - } else if (i == valleyIndex) { - v = -9999.0 // 극단적 밸리 - } - buffer.addSample(t, v) - } - - val downsampled = DownsamplingAlgorithms.minMaxDownsample( - buffer = buffer, - firstIdx = 0, - lastIdx = totalPoints - 1, - tMin = 0.0, - tMax = 50.0, - screenWidthPx = 300 - ) - - val values = downsampled.map { it.second } - val maxVal = values.maxOrNull() ?: Double.MIN_VALUE - val minVal = values.minOrNull() ?: Double.MAX_VALUE - - assertEquals(9999.0, maxVal, 1e-6, "최대 피크가 완벽히 보존되어야 합니다") - assertEquals(-9999.0, minVal, 1e-6, "최소 밸리가 완벽히 보존되어야 합니다") - } - - @Test - @DisplayName("계단형 PWM 디지털 신호의 On/Off 엣지가 손실 없이 보존되어야 한다") - fun testPwmSignalEdgePreservation() { - val totalPoints = 2_000 - val buffer = TimeSeriesRingBuffer(totalPoints) - - // 100ms마다 0과 1이 교차하는 PWM 신호 - for (i in 0 until totalPoints) { - val t = i * 0.01 - val v = if ((i / 10) % 2 == 0) 1.0 else 0.0 - buffer.addSample(t, v) - } - - val downsampled = DownsamplingAlgorithms.minMaxDownsample( - buffer = buffer, - firstIdx = 0, - lastIdx = totalPoints - 1, - tMin = 0.0, - tMax = 20.0, - screenWidthPx = 200 - ) - - val values = downsampled.map { it.second } - assertTrue(values.contains(0.0), "PWM Low 레벨이 포함되어야 합니다") - assertTrue(values.contains(1.0), "PWM High 레벨이 포함되어야 합니다") - } - - @Test - @DisplayName("10,000개 포인트 다운샘플링 수행 시간이 10ms 이내여야 한다") - fun testPerformanceBenchmark() { - val totalPoints = 10_000 - val buffer = TimeSeriesRingBuffer(totalPoints) - for (i in 0 until totalPoints) { - buffer.addSample(i * 0.001, (i % 50).toDouble()) - } - - // 워밍업 - DownsamplingAlgorithms.minMaxDownsample(buffer, 0, totalPoints - 1, 0.0, 10.0, 1000) - - val elapsed = measureTimeMillis { - for (iter in 0 until 10) { - DownsamplingAlgorithms.minMaxDownsample(buffer, 0, totalPoints - 1, 0.0, 10.0, 1000) - } - } - - val avgTime = elapsed / 10.0 - assertTrue(avgTime < 10.0, "평균 다운샘플링 소요 시간이 10ms 이내여야 합니다 (실제: ${avgTime}ms)") - } - - @Test - @DisplayName("다운샘플링 시 단절 NaN 마커가 같은 픽셀 버킷에 흡수되지 않아야 한다") - fun testGapMarkerSurvivesDownsampling() { - val buffer = TimeSeriesRingBuffer(7_000) - for (i in 0 until 3_500) buffer.addSample(i * 0.01, 1.0) - buffer.addGap(35.0) - for (i in 3_501 until 7_000) buffer.addSample(i * 0.01, 2.0) - - val points = DownsamplingAlgorithms.minMaxDownsample(buffer, 0, buffer.size - 1, 0.0, 70.0, 100) - assertTrue(points.any { it.second.isNaN() }, "단절 마커가 다운샘플링 결과에 남아야 합니다") - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt b/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt deleted file mode 100644 index c75975d..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt +++ /dev/null @@ -1,203 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.chart.ChartAxisConfig -import com.dct.app.domain.model.chart.MultiAxisCoordinateTransformer -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -@DisplayName("MultiAxisCoordinateTransformer 단일축 및 다중축 좌표 변환 단위 테스트 (PC 레거시 1:1 검증)") -class MultiAxisCoordinateTransformTest { - - @Test - @DisplayName("시간에서 스크린 X 픽셀로의 변환 및 역변환 Round-trip 오차가 0.001% 미만이어야 한다") - fun testTimeCoordinateRoundTrip() { - val tMin = 10.0 - val tMax = 30.0 - val plotLeft = 100f - val plotWidth = 800f - - val testTimes = listOf(10.0, 15.0, 20.0, 25.5, 30.0) - - for (t in testTimes) { - val screenX = MultiAxisCoordinateTransformer.timeToScreenX( - timeSec = t, - tMinSec = tMin, - tMaxSec = tMax, - plotLeft = plotLeft, - plotWidth = plotWidth - ) - - if (t == 10.0) assertEquals(100f, screenX, 1e-4f) - if (t == 30.0) assertEquals(900f, screenX, 1e-4f) - if (t == 20.0) assertEquals(500f, screenX, 1e-4f) - - val recoveredTime = MultiAxisCoordinateTransformer.screenXToTime( - screenX = screenX, - tMinSec = tMin, - tMaxSec = tMax, - plotLeft = plotLeft, - plotWidth = plotWidth - ) - - val error = kotlin.math.abs(recoveredTime - t) - assertTrue(error < 1e-5, "Round-trip 시간 오차가 1e-5 미만이어야 함 (오차: $error)") - } - } - - @Test - @DisplayName("서로 다른 4개 Y축 범위에 대해 물리값과 Y픽셀 간의 정확한 독립 변환이 이루어져야 한다") - fun testMultiAxisValueToScreenY() { - val plotTop = 50f - val plotHeight = 400f - - val rpmAxis = ChartAxisConfig( - position = AxisPosition.LEFT_0, - minVal = 0.0, - maxVal = 8000.0 - ) - val y0 = MultiAxisCoordinateTransformer.valueToScreenY(0.0, rpmAxis.minVal, rpmAxis.maxVal, plotTop, plotHeight) - assertEquals(450f, y0, 1e-4f) - - val yMax = MultiAxisCoordinateTransformer.valueToScreenY(8000.0, rpmAxis.minVal, rpmAxis.maxVal, plotTop, plotHeight) - assertEquals(50f, yMax, 1e-4f) - - val yMid = MultiAxisCoordinateTransformer.valueToScreenY(4000.0, rpmAxis.minVal, rpmAxis.maxVal, plotTop, plotHeight) - assertEquals(250f, yMid, 1e-4f) - - val tempAxis = ChartAxisConfig( - position = AxisPosition.LEFT_1, - minVal = -40.0, - maxVal = 120.0 - ) - assertEquals(450f, MultiAxisCoordinateTransformer.valueToScreenY(-40.0, tempAxis.minVal, tempAxis.maxVal, plotTop, plotHeight), 1e-4f) - assertEquals(50f, MultiAxisCoordinateTransformer.valueToScreenY(120.0, tempAxis.minVal, tempAxis.maxVal, plotTop, plotHeight), 1e-4f) - assertEquals(250f, MultiAxisCoordinateTransformer.valueToScreenY(40.0, tempAxis.minVal, tempAxis.maxVal, plotTop, plotHeight), 1e-4f) - - val voltAxis = ChartAxisConfig( - position = AxisPosition.LEFT_2, - minVal = 10.0, - maxVal = 15.0 - ) - assertEquals(450f, MultiAxisCoordinateTransformer.valueToScreenY(10.0, voltAxis.minVal, voltAxis.maxVal, plotTop, plotHeight), 1e-4f) - assertEquals(50f, MultiAxisCoordinateTransformer.valueToScreenY(15.0, voltAxis.minVal, voltAxis.maxVal, plotTop, plotHeight), 1e-4f) - assertEquals(250f, MultiAxisCoordinateTransformer.valueToScreenY(12.5, voltAxis.minVal, voltAxis.maxVal, plotTop, plotHeight), 1e-4f) - - val throttleAxis = ChartAxisConfig( - position = AxisPosition.LEFT_3, - minVal = 0.0, - maxVal = 100.0 - ) - assertEquals(450f, MultiAxisCoordinateTransformer.valueToScreenY(0.0, throttleAxis.minVal, throttleAxis.maxVal, plotTop, plotHeight), 1e-4f) - assertEquals(50f, MultiAxisCoordinateTransformer.valueToScreenY(100.0, throttleAxis.minVal, throttleAxis.maxVal, plotTop, plotHeight), 1e-4f) - assertEquals(250f, MultiAxisCoordinateTransformer.valueToScreenY(50.0, throttleAxis.minVal, throttleAxis.maxVal, plotTop, plotHeight), 1e-4f) - - val recoveredRpm = MultiAxisCoordinateTransformer.screenYToValue(250f, rpmAxis.minVal, rpmAxis.maxVal, plotTop, plotHeight) - assertEquals(4000.0, recoveredRpm, 1e-4) - } - - @Test - @DisplayName("축 경계에 닿은 파형은 선 두께만큼 안쪽에 보이고 범위 밖 값은 클리핑 좌표를 유지한다") - fun testVisibleSignalStrokeAtPlotEdges() { - val top = 50f - val height = 400f - assertEquals(53f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(100.0, 0.0, 100.0, top, height, 3f), 1e-4f) - assertEquals(447f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(0.0, 0.0, 100.0, top, height, 3f), 1e-4f) - assertEquals(250f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(50.0, 0.0, 100.0, top, height, 3f), 1e-4f) - assertEquals(52f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(100.0, 0.0, 100.0, top, height, 1f), 1e-4f) - assertEquals(49f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(100.25, 0.0, 100.0, top, height, 3f), 1e-4f) - assertEquals(451f, MultiAxisCoordinateTransformer.valueToVisibleStrokeY(-0.25, 0.0, 100.0, top, height, 3f), 1e-4f) - assertEquals(50f, MultiAxisCoordinateTransformer.valueToScreenY(100.0, 0.0, 100.0, top, height), 1e-4f) - } - - @Test - @DisplayName("PC CChartStandardAxis 알고리즘에 따라 [-50 ~ 100] 범위는 10단위 16개 눈금이 정확히 생성되어야 한다") - fun testGenerateStandardTicksLegacyPC() { - val ticks = MultiAxisCoordinateTransformer.generateStandardTicks( - minVal = -50.0, - maxVal = 100.0, - heightPx = 300f, - pixelSpace = 20 - ) - - val expected = listOf(-50.0, -40.0, -30.0, -20.0, -10.0, 0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0) - assertEquals(expected.size, ticks.size, "눈금 개수는 16개여야 함") - for (i in expected.indices) { - assertEquals(expected[i], ticks[i], 1e-5, "인덱스 $i 눈금 불일치") - } - } - - @Test - @DisplayName("PC CChartDateTimeAxis 알고리즘에 따라 시간 타임스탬프가 정수 스냅 단위로 정렬 생성되어야 한다") - fun testGenerateTimeTicksLegacyPC() { - // 30초 윈도우 (56850.0 ~ 56880.0) - val timeTicks = MultiAxisCoordinateTransformer.generateTimeTicks( - tMinSec = 56850.0, - tMaxSec = 56880.0, - widthPx = 500f, - pixelSpace = 100 - ) - - assertTrue(timeTicks.isNotEmpty()) - for (t in timeTicks) { - // 모든 틱이 5초 또는 10초, 30초 등 정수 초 단위로 스냅 정렬 - assertEquals(0.0, t % 1.0, 1e-5, "틱은 정수 초 단위여야 함") - } - } - - @Test - @DisplayName("현재 그래프 여백: 단일축 58px, 다중축 12px + 축 너비 합계") - fun testLeftMarginCalculations() { - val widthPerAxis = 42f - - assertEquals(58f, MultiAxisCoordinateTransformer.calculateLeftMargin(1, isMultiAxis = false, axisWidthPx = widthPerAxis), 1e-4f) - assertEquals(58f, MultiAxisCoordinateTransformer.calculateLeftMargin(3, isMultiAxis = false, axisWidthPx = widthPerAxis), 1e-4f) - - assertEquals(58f, MultiAxisCoordinateTransformer.calculateLeftMargin(1, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) - assertEquals(96f, MultiAxisCoordinateTransformer.calculateLeftMargin(2, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) - assertEquals(138f, MultiAxisCoordinateTransformer.calculateLeftMargin(3, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) - assertEquals(180f, MultiAxisCoordinateTransformer.calculateLeftMargin(4, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) - assertEquals(180f, MultiAxisCoordinateTransformer.calculateLeftMargin(5, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) - } - - @Test - @DisplayName("일반 그래프는 우측 외부 범례, 다중축 그래프는 하단 외부 범례 여백을 정확히 계산해야 한다") - fun testPlotBoundsByGraphType() { - val canvasWidth = 1000f - val canvasHeight = 600f - - // 1. 일반 그래프 (단일축, 우측 외부 범례 120px) - val singleBounds = MultiAxisCoordinateTransformer.calculatePlotBounds( - canvasWidth = canvasWidth, - canvasHeight = canvasHeight, - leftAxisCount = 1, - isMultiAxis = false, - hasLegend = true, - legendWidthPx = 110f - ) - assertEquals(58f, singleBounds.left, 1e-4f) - // rightMargin = (110 + 28).coerceIn(110..240) = 138f -> right = 1000 - 138 = 862f - assertEquals(862f, singleBounds.right, 1e-4f) - assertEquals(12f, singleBounds.top, 1e-4f) - // bottomMargin = 24f -> bottom = 600 - 24 = 576f - assertEquals(576f, singleBounds.bottom, 1e-4f) - - // 2. 다중축 그래프 (다중축 2개, 하단 외부 범례 62px) - val multiBounds = MultiAxisCoordinateTransformer.calculatePlotBounds( - canvasWidth = canvasWidth, - canvasHeight = canvasHeight, - leftAxisCount = 2, - isMultiAxis = true, - hasLegend = true, - legendPosition = 2, - axisWidthPx = 42f - ) - assertEquals(96f, multiBounds.left, 1e-4f) - assertEquals(984f, multiBounds.right, 1e-4f) - assertEquals(12f, multiBounds.top, 1e-4f) - // bottomMargin = 24 + 32 + 4 = 60f - assertEquals(540f, multiBounds.bottom, 1e-4f) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketFramingAndCodecTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketFramingAndCodecTest.kt deleted file mode 100644 index 9ec7235..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/PacketFramingAndCodecTest.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.dct.app.tests - -import com.google.common.truth.Truth.assertThat -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.domain.model.protocol.DctCommand -import com.dct.app.domain.model.protocol.DctPacket -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.EnumSource - -@DisplayName("PacketFramingAndCodec (DctPacketCodec) 인코딩/디코딩 단위 테스트") -class PacketFramingAndCodecTest { - - private lateinit var codec: DctPacketCodec - - @BeforeEach - fun setUp() { - codec = DctPacketCodec() - } - - @Test - @DisplayName("C++ 호환 ENABLE 커맨드 패킷 인코딩 구조 및 바이트 정합성 검증") - fun testEncodeEnableCommand() { - // ENABLE 커맨드 (0x00), Payload 0바이트 -> packLen = 1 - val packet = DctPacket.create(DctCommand.CMD_ENABLE) - val encoded = codec.encode(packet) - - // 기대 크기: packLen(1) + 5 = 6 Bytes - assertThat(encoded.size).isEqualTo(6) - assertThat(encoded[0]).isEqualTo(0xA1.toByte()) // Header - assertThat(encoded[1]).isEqualTo(0x00.toByte()) // LenH - assertThat(encoded[2]).isEqualTo(0x01.toByte()) // LenL - assertThat(encoded[3]).isEqualTo(0x00.toByte()) // CmdID (CMD_ENABLE) - - // Checksum: (0xA1 + 0x00 + 0x01 + 0x00 = 0xA2) ^ 0xFF = 0x5D - assertThat(encoded[4]).isEqualTo(0x5D.toByte()) - assertThat(encoded[5]).isEqualTo(0x7E.toByte()) // Footer - - // 디코딩 검증 - val decoded = codec.decode(encoded) - assertThat(decoded).isNotNull() - assertThat(decoded?.command).isEqualTo(DctCommand.CMD_ENABLE.code) - assertThat(decoded?.payload).isEmpty() - } - - @ParameterizedTest - @EnumSource(DctCommand::class) - @DisplayName("모든 DctCommand에 대한 인코딩 및 디코딩 왕복(Round-trip) 무결성 검증") - fun testAllCommandsRoundTrip(command: DctCommand) { - val dummyPayload = byteArrayOf(0x01, 0x02, 0x03, 0x04) - val originalPacket = DctPacket.create(command, dummyPayload, sequence = 42) - - val frame = codec.encode(originalPacket) - assertThat(codec.isValidFrame(frame)).isTrue() - - val decoded = codec.decode(frame, sequence = 42) - assertThat(decoded).isNotNull() - assertThat(decoded?.command).isEqualTo(command.code) - assertThat(decoded?.payload).isEqualTo(dummyPayload) - assertThat(decoded?.sequence).isEqualTo(42) - } - - @Test - @DisplayName("Big-Endian 길이 필드 직렬화 검증 (대용량 1000바이트 페이로드)") - fun testLargePayloadBigEndianLengthEncoding() { - val largePayload = ByteArray(1000) { (it and 0xFF).toByte() } - val packet = DctPacket(command = DctCommand.SEND_CAN_FRAME.code, payload = largePayload) - - val encoded = codec.encode(packet) - val packLen = 1000 + 1 // 1001 = 0x03E9 - assertThat(packLen).isEqualTo(1001) - - assertThat(encoded.size).isEqualTo(1001 + 5) - assertThat(encoded[0]).isEqualTo(0xA1.toByte()) - assertThat(encoded[1]).isEqualTo(0x03.toByte()) // LenH - assertThat(encoded[2]).isEqualTo(0xE9.toByte()) // LenL - assertThat(encoded[3]).isEqualTo(DctCommand.SEND_CAN_FRAME.code) - assertThat(encoded[encoded.size - 1]).isEqualTo(0x7E.toByte()) - - val decoded = codec.decode(encoded) - assertThat(decoded).isNotNull() - assertThat(decoded?.payload?.size).isEqualTo(1000) - assertThat(decoded?.payload).isEqualTo(largePayload) - } - - @Test - @DisplayName("손상된 프레임은 decode 실패 및 isValidFrame false 반환") - fun testCorruptedFrames() { - val validPacket = DctPacket.create(DctCommand.CMD_VERSION, "V1.0".toByteArray()) - val validFrame = codec.encode(validPacket) - - // 1. 헤더 바이트 손상 - val badHeader = validFrame.copyOf() - badHeader[0] = 0x00 - assertThat(codec.decode(badHeader)).isNull() - assertThat(codec.isValidFrame(badHeader)).isFalse() - - // 2. 푸터 바이트 손상 - val badFooter = validFrame.copyOf() - badFooter[badFooter.size - 1] = 0x00 - assertThat(codec.decode(badFooter)).isNull() - assertThat(codec.isValidFrame(badFooter)).isFalse() - - // 3. 길이 필드 변조 - val badLen = validFrame.copyOf() - badLen[2] = (badLen[2] + 1).toByte() - assertThat(codec.decode(badLen)).isNull() - assertThat(codec.isValidFrame(badLen)).isFalse() - - // 4. 체크섬 바이트 변조 - val badChecksum = validFrame.copyOf() - badChecksum[badChecksum.size - 2] = (badChecksum[badChecksum.size - 2] + 1).toByte() - assertThat(codec.decode(badChecksum)).isNull() - assertThat(codec.isValidFrame(badChecksum)).isFalse() - - // 5. 프레임 길이가 너무 짧은 경우 (5바이트) - val tooShort = ByteArray(5) { 0xA1.toByte() } - assertThat(codec.decode(tooShort)).isNull() - assertThat(codec.isValidFrame(tooShort)).isFalse() - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketPipelineMetricsTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketPipelineMetricsTest.kt deleted file mode 100644 index 3390812..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/PacketPipelineMetricsTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.PacketPipelineMetrics -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class PacketPipelineMetricsTest { - @Test fun receiveToProcessAgeTracksQueueWaitSeparatelyFromProcessing() { - val metrics = PacketPipelineMetrics() - assertEquals(0.0, metrics.snapshot().averageReceiveToProcessMillis) - metrics.recordReceiveToProcess(2_000_000) - metrics.recordReceiveToProcess(8_000_000) - metrics.recordReceiveToProcess(0) - metrics.recordReceiveToProcess(-1) - metrics.recordProcessing(100_000) - val result = metrics.snapshot() - assertEquals(3L, result.receiveToProcessCount) - assertEquals(10.0 / 3, result.averageReceiveToProcessMillis, 0.000001) - assertEquals(0L, result.lastReceiveToProcessNanos) - assertEquals(8_000_000L, result.maxReceiveToProcessNanos) - assertEquals(100.0, result.averageProcessingMicros) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt deleted file mode 100644 index c88e790..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.PacketDistributor -import com.dct.app.data.communication.OrderedPacketWriter -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.data.protocol.SlidingRingBufferParser -import com.dct.app.domain.model.protocol.DctPacket -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.collect -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger - -class PacketPipelineReliabilityTest { - @Test fun consecutiveAcksAndControlWaitFiveMillisecondsAfterThePreviousWrite() = runBlocking { - val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val entered = CountDownLatch(1) - val releaseFirst = CountDownLatch(1) - val finished = CountDownLatch(4) - val commands = mutableListOf() - val startedAt = mutableListOf() - val completedAt = mutableListOf() - val writer = OrderedPacketWriter(scope, write = { bytes -> - commands += bytes[0].toInt() - startedAt += System.nanoTime() - if (bytes[0] == 0x24.toByte()) { - entered.countDown() - check(releaseFirst.await(2, TimeUnit.SECONDS)) - } - // A slow write must not consume the following frame's quiet interval. - Thread.sleep(10) - completedAt += System.nanoTime() - finished.countDown() - }, closeTransport = { releaseFirst.countDown() }) - try { - assertTrue(writer.offer(byteArrayOf(0x24))) - assertTrue(entered.await(2, TimeUnit.SECONDS)) - assertTrue(writer.offer(byteArrayOf(0x23))) - assertTrue(writer.offer(byteArrayOf(0x50))) - val control = async(start = CoroutineStart.UNDISPATCHED) { writer.send(byteArrayOf(0x05)) } - releaseFirst.countDown() - assertTrue(finished.await(3, TimeUnit.SECONDS)) - assertTrue(control.await()) - assertEquals(listOf(0x24, 0x05, 0x23, 0x50), commands, - "Control priority and ACK ordering must survive pacing") - for (index in 1 until startedAt.size) { - val quietNanos = startedAt[index] - completedAt[index - 1] - assertTrue(quietNanos >= 5_000_000L, - "Frame $index began after only ${quietNanos / 1_000_000.0}ms of quiet") - } - } finally { writer.close(); scope.cancel() } - } - - @Test fun cancelledCallerDoesNotCloseSharedWriter() = runBlocking { - val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val entered = CountDownLatch(1) - val gate = CountDownLatch(1) - val closed = AtomicInteger() - val writer = OrderedPacketWriter(scope, write = { - entered.countDown() - check(gate.await(3, TimeUnit.SECONDS)) - }, closeTransport = { closed.incrementAndGet(); gate.countDown() }) - try { - val caller = launch(start = CoroutineStart.UNDISPATCHED) { writer.send(byteArrayOf(1)) } - assertTrue(entered.await(2, TimeUnit.SECONDS)) - caller.cancelAndJoin() - assertEquals(0, closed.get()) - gate.countDown() - assertTrue(writer.send(byteArrayOf(2))) - } finally { writer.close(); scope.cancel() } - } - - @Test fun stalledSubscriberReceivesAll1001PacketsInOrder() = runBlocking { - val distributor = PacketDistributor() - val gate = CompletableDeferred() - val received = mutableListOf() - val done = CompletableDeferred() - val consumer = launch(start = CoroutineStart.UNDISPATCHED) { - distributor.packetStream.collect { - received.add(it.sequence) - if (it.sequence == 0) gate.await() - if (it.sequence == 1000) done.complete(Unit) - } - } - val producer = launch { - repeat(1001) { distributor.dispatchPacket(DctPacket(0xA3.toByte(), sequence = it)) } - } - yield() - assertFalse(producer.isCompleted, "A full buffer must suspend, not discard data") - gate.complete(Unit) - withTimeout(3000) { producer.join(); done.await() } - consumer.cancelAndJoin() - assertEquals((0..1000).toList(), received) - } - - @Test fun maximumWireFrameAndOversizedCoalescedReadAreLossless() { - val codec = DctPacketCodec() - val payload = ByteArray(65534) { (it % 256).toByte() } - val frame = codec.encode(DctPacket(0xA3.toByte(), payload)) - assertEquals(65540, frame.size) - val stream = frame + frame + frame - val parsed = SlidingRingBufferParser().parseStream(stream) - assertEquals(3, parsed.size) - parsed.forEach { assertArrayEquals(payload, it.payload) } - val fragmented = SlidingRingBufferParser() - assertTrue(fragmented.parseStream(frame.copyOfRange(0, 65535)).isEmpty()) - assertArrayEquals(payload, fragmented.parseStream(frame.copyOfRange(65535, frame.size)).single().payload) - assertThrows(IllegalArgumentException::class.java) { codec.encode(DctPacket(0xA3.toByte(), ByteArray(65535))) } - } - - @Test fun concurrentWritersNeverOverlapAndPreserveEnqueueOrder() = runBlocking { - val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val entered = CountDownLatch(1) - val gate = CountDownLatch(1) - val values = mutableListOf() - val active = AtomicInteger() - val overlaps = AtomicInteger() - val writer = OrderedPacketWriter(scope, write = { - if (active.incrementAndGet() != 1) overlaps.incrementAndGet() - if (it[0] == 0.toByte()) { entered.countDown(); gate.await(2, TimeUnit.SECONDS) } - values.add(it[0].toInt()) - active.decrementAndGet() - }, closeTransport = { gate.countDown() }) - try { - val first = async(start = CoroutineStart.UNDISPATCHED) { writer.send(byteArrayOf(0)) } - assertTrue(entered.await(2, TimeUnit.SECONDS)) - val others = (1..20).map { async(start = CoroutineStart.UNDISPATCHED) { writer.send(byteArrayOf(it.toByte())) } } - gate.countDown() - assertTrue(first.await()) - assertTrue(others.awaitAll().all { it }) - assertEquals((0..20).toList(), values) - assertEquals(0, overlaps.get()) - } finally { writer.close(); scope.cancel() } - } - - @Test fun blockedWriteTimeoutDoesNotCloseTransport() = runBlocking { - val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val gate = CountDownLatch(1) - val closed = AtomicInteger() - val writes = AtomicInteger() - val entered = CountDownLatch(1) - val writer = OrderedPacketWriter(scope, write = { - entered.countDown() - gate.await(2, TimeUnit.SECONDS) - writes.incrementAndGet() - }, closeTransport = { closed.incrementAndGet(); gate.countDown() }, timeoutMs = 100) - try { - withTimeout(2000) { - val first = async { writer.send(byteArrayOf(1)) } - withContext(Dispatchers.IO) { assertTrue(entered.await(1, TimeUnit.SECONDS)) } - val queued = async { writer.send(byteArrayOf(2)) } - assertFalse(first.await()) - assertFalse(queued.await()) - assertEquals(0, closed.get(), "A caller timeout must not close the socket") - gate.countDown() - delay(100) - assertTrue(writer.send(byteArrayOf(3)), "A fresh command must still be writable") - assertEquals(2, writes.get(), "The expired queued command must never reach the socket") - } - assertEquals(0, closed.get()) - } finally { writer.close(); scope.cancel() } - assertEquals(1, closed.get()) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt deleted file mode 100644 index d7dcf6d..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt +++ /dev/null @@ -1,239 +0,0 @@ -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.repository.PacketRepositoryImpl -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.domain.repository.DeviceConnectionState -import io.mockk.* -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -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 - -class PacketRepositoryEnableRefreshTest { - private class Fixture(val testScope: TestScope, timeoutMs: Long = 50) { - val wifi = mockk(relaxed = true) - val packets = MutableSharedFlow(extraBufferCapacity = 16) - val connected = MutableStateFlow(true) - var sessionId = 7L - val sent = mutableListOf() - val writtenAt = mutableListOf>() - val acks = mutableListOf() - var versionDelay = 0L - var silent: Byte? = null - var sendFailure: Byte? = null - var versionPayload = byteArrayOf(1, 65) - var environmentRejected = false - - init { - every { wifi.isConnected } returns connected - every { wifi.currentSessionId } answers { sessionId } - every { wifi.packetStream } returns packets - every { wifi.offerCommand(capture(acks)) } returns true - every { wifi.invalidateConnectionIfSession(any()) } answers { - if (firstArg() == sessionId && connected.value) { connected.value = false; true } else false - } - coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false } - coEvery { wifi.sendCommand(any()) } coAnswers { - val request = firstArg() - sent += request - if (request.command == sendFailure) return@coAnswers false - if (request.command == 1.toByte()) kotlinx.coroutines.delay(versionDelay) - writtenAt += request to testScope.testScheduler.currentTime - if (request.command != silent && request.command != 0x0F.toByte()) { - val payload = when (request.command.toInt()) { - 1 -> versionPayload - 3 -> ByteArray(6) - 9 -> byteArrayOf(request.payload[0], if (environmentRejected) 2 else 1) - 0x20 -> byteArrayOf(1, 1) - else -> byteArrayOf(1) - } - packets.emit(DctPacket((request.command + 0x80).toByte(), payload, - transportSessionId = request.transportSessionId)) - } - true - } - } - - val repository = PacketRepositoryImpl(wifiSocketManager = wifi, - ioDispatcher = UnconfinedTestDispatcher(testScope.testScheduler), responseTimeoutMs = timeoutMs, - enableHeartbeat = false, monotonicTimeMillis = { testScope.testScheduler.currentTime }, scope = testScope.backgroundScope) - - suspend fun open(measuring: Boolean = false) { - assertTrue(repository.connect("") is DctResult.Success) - if (measuring) assertTrue(repository.startStreaming(30) is DctResult.Success) - sent.clear(); writtenAt.clear() - } - - suspend fun enable(payload: ByteArray = byteArrayOf(1), session: Long = sessionId) { - packets.emit(DctPacket(0x80.toByte(), payload, transportSessionId = session)) - } - } - - @Test - fun readyEnableRefreshesVersionAndReadOnlyEnvironmentWithoutInterruptingAcks() = runTest { - val f = Fixture(this) - f.open() - f.versionDelay = 25 - f.enable() - runCurrent() - // Keep receiving/ACKing while VERSION's actual write is still pending. - f.packets.emit(DctPacket(0xA3.toByte(), byteArrayOf(1, 0xA0.toByte(), 9), transportSessionId = 7)) - f.enable(); f.enable() - runCurrent() - assertEquals(1, f.acks.size) - assertEquals(0x23.toByte(), f.acks.single().command) - assertEquals(listOf(1), f.sent.map { it.command.toInt() }) - advanceTimeBy(224); runCurrent() - assertEquals(1, f.sent.size) - advanceTimeBy(1); runCurrent() - assertEquals(listOf(1, 9, 9), f.sent.map { it.command.toInt() }) - assertEquals(listOf(0, 3), f.sent.filter { it.command == 9.toByte() }.map { it.payload.single().toInt() }) - assertEquals(200L, f.writtenAt[1].second - f.writtenAt[0].second) - assertTrue(f.sent.all { it.transportSessionId == 7L }) - assertFalse(f.repository.isStreaming.value) - assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) - assertTrue(f.connected.value) - verify(exactly = 1) { f.wifi.diagnosticEvent(7L, match { it.startsWith("ENABLE_REFRESH_COMPLETE") }) } - coVerify(exactly = 0) { f.wifi.disconnectSuspend() } - f.enable() - advanceTimeBy(225); runCurrent() - assertEquals(listOf(1, 9, 9, 1, 9, 9), f.sent.map { it.command.toInt() }, - "A later ENABLE must be able to refresh again after completion") - } - - @Test fun matchingInitialEnableReplyDoesNotStartAnotherRefresh() = runTest { - val f = Fixture(this) - assertTrue(f.repository.connect("") is DctResult.Success) - advanceTimeBy(500); runCurrent() - assertEquals(1, f.sent.count { it.command == 1.toByte() }) - assertEquals(2, f.sent.count { it.command == 9.toByte() }) - verify(exactly = 0) { f.wifi.diagnosticEvent(any(), match { it.startsWith("ENABLE_REFRESH_") }) } - } - - @Test fun versionReplyAfter200msDoesNotAddAnother200msBeforeEnvironment() = runTest { - val f = Fixture(this, timeoutMs = 1000) - f.open() - coEvery { f.wifi.sendCommand(match { it.command == 1.toByte() }) } coAnswers { - val request = firstArg() - f.sent += request - f.writtenAt += request to testScheduler.currentTime - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - kotlinx.coroutines.delay(250) - f.packets.emit(DctPacket(0x81.toByte(), byteArrayOf(1, 65), transportSessionId = 7)) - } - true - } - f.enable() - advanceTimeBy(249); runCurrent() - assertEquals(listOf(1), f.sent.map { it.command.toInt() }) - advanceTimeBy(1); runCurrent() - assertEquals(listOf(1, 9, 9), f.sent.map { it.command.toInt() }) - assertEquals(250L, f.writtenAt[1].second - f.writtenAt[0].second) - } - - @ParameterizedTest - @ValueSource(strings = ["empty", "nack", "extra", "stale"]) - fun invalidOrOldSessionEnableDoesNotQuery(failure: String) = runTest { - val f = Fixture(this) - f.open() - when (failure) { - "empty" -> f.enable(byteArrayOf()) - "nack" -> f.enable(byteArrayOf(2)) - "extra" -> f.enable(byteArrayOf(1, 1)) - else -> f.enable(session = 6) - } - advanceTimeBy(500); runCurrent() - assertTrue(f.sent.isEmpty()) - assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) - } - - @ParameterizedTest - @ValueSource(strings = ["versionTimeout", "versionMalformed", "versionSendFailed", "environmentTimeout", "environmentRejected"]) - fun failedReadyRefreshReportsWithoutStartingMeasurementOrClosingTcp(failure: String) = runTest { - val f = Fixture(this) - f.open() - when (failure) { - "versionTimeout" -> f.silent = 1 - "versionMalformed" -> f.versionPayload = byteArrayOf(3, 65) - "versionSendFailed" -> f.sendFailure = 1 - "environmentTimeout" -> f.silent = 9 - else -> f.environmentRejected = true - } - f.enable() - advanceTimeBy(500); runCurrent() - assertEquals(if (failure.startsWith("version")) listOf(1) else listOf(1, 9), - f.sent.map { it.command.toInt() }) - assertTrue(f.connected.value) - assertFalse(f.repository.isStreaming.value) - assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) - verify(exactly = 1) { f.wifi.diagnosticEvent(7L, match { it.startsWith("ENABLE_REFRESH_FAILED") }) } - coVerify(exactly = 0) { f.wifi.disconnectSuspend() } - } - - @Test fun disconnectCancelsRefreshDuringTheVersionEnvironmentGap() = runTest { - val f = Fixture(this) - f.open() - f.enable(); runCurrent() - assertEquals(listOf(1), f.sent.map { it.command.toInt() }) - assertTrue(f.repository.disconnect() is DctResult.Success) - advanceTimeBy(500); runCurrent() - assertEquals(listOf(1, 15), f.sent.map { it.command.toInt() }) - assertEquals(DeviceConnectionState.DISCONNECTED, f.repository.connectionState.value) - verify(exactly = 0) { f.wifi.diagnosticEvent(any(), match { it.startsWith("ENABLE_REFRESH_COMPLETE") }) } - } - - @Test fun transportLossCancelsOldRefreshAndOldRepliesCannotStartWorkOnNewSession() = runTest { - val f = Fixture(this) - f.open() - f.enable(); runCurrent() - f.connected.value = false; runCurrent() - f.sessionId = 8 - f.connected.value = true - f.open() - f.enable(session = 7) - advanceTimeBy(500); runCurrent() - assertTrue(f.sent.isEmpty()) - f.enable(session = 8) - advanceTimeBy(200); runCurrent() - assertEquals(listOf(1, 9, 9), f.sent.map { it.command.toInt() }) - assertTrue(f.sent.all { it.transportSessionId == 8L }) - } - - @Test fun enableDuringExplicitDisconnectCannotStartAnotherRefresh() = runTest { - val f = Fixture(this) - f.open(measuring = true) - f.silent = 0x21 - val disconnect = async { f.repository.disconnect() } - runCurrent() - f.enable() - advanceTimeBy(50); runCurrent() - assertTrue(disconnect.await() is DctResult.Success) - assertEquals(listOf(0x21, 0x0F), f.sent.map { it.command.toInt() }) - verify(exactly = 0) { f.wifi.diagnosticEvent(any(), match { it.startsWith("ENABLE_REFRESH_START") }) } - } - - @Test fun commandWaitingForMutexCannotBeRetaggedToAnotherSession() = runTest { - val f = Fixture(this) - f.open() - f.silent = 3 - val blocking = async { f.repository.sendCommand(DctProtocolRequests.hardwareId()) } - runCurrent() - val waiting = async { f.repository.sendCommand(DctProtocolRequests.version().copy(transportSessionId = 7)) } - runCurrent() - f.sessionId = 8 - advanceTimeBy(50); runCurrent() - assertTrue(blocking.await() is DctResult.Failure) - assertTrue(waiting.await() is DctResult.Failure) - assertEquals(listOf(3), f.sent.map { it.command.toInt() }) - assertTrue(f.repository.sendCommand(DctProtocolRequests.version().copy(transportSessionId = 7)) is DctResult.Failure) - assertEquals(listOf(3), f.sent.map { it.command.toInt() }) - } -} 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 deleted file mode 100644 index 74683c1..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt +++ /dev/null @@ -1,659 +0,0 @@ -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 -import io.mockk.* -import kotlinx.coroutines.async -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.CsvSource -import org.junit.jupiter.params.provider.ValueSource - -class PacketRepositoryResponseTest { - @Test fun collectorExceptionAndActualResubscriptionAreObservableWithoutChangingRetry() = runTest { - val f = Fixture(UnconfinedTestDispatcher(testScheduler)) - every { f.wifi.offerCommand(any()) } throws IllegalStateException("injected ACK failure") - f.packets.emit(DctPacket(DctCommand.SEND_CAN_FRAME.code, byteArrayOf(1, 0xA0.toByte(), 1), ingressSequence = 42)) - runCurrent() - verify { f.wifi.diagnosticEvent(any(), match { it.contains("COLLECTOR_ERROR") && it.contains("ingress=42") && it.contains("injected ACK failure") }) } - assertEquals(0, f.packets.subscriptionCount.value) - advanceTimeBy(99); runCurrent() - assertEquals(0, f.packets.subscriptionCount.value) - every { f.wifi.offerCommand(any()) } returns true - advanceTimeBy(1); runCurrent() - assertEquals(1, f.packets.subscriptionCount.value) - verify { f.wifi.diagnosticEvent(any(), match { it.contains("COLLECTOR_RESUBSCRIBED") }) } - f.packets.emit(DctPacket(DctCommand.SEND_CAN_FRAME.code, byteArrayOf(1, 0xA0.toByte(), 2))) - runCurrent() - verify(exactly = 2) { f.wifi.offerCommand(any()) } - } - @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 } - val before = testScheduler.currentTime - assertTrue(f.repository.sendCommand(DctProtocolRequests.version()) is DctResult.Failure) - assertEquals(50, testScheduler.currentTime - before) - coVerify(exactly = 0) { f.wifi.disconnectSuspend() } - } - - @Test fun streamAcksValidateStructureAndNeverAcknowledgeOldSessions() = runTest { - val f = Fixture(UnconfinedTestDispatcher(testScheduler)) - every { f.wifi.currentSessionId } returns 7L - val sent = mutableListOf() - every { f.wifi.offerCommand(capture(sent)) } returns true - f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(0), transportSessionId = 6)) - f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(1), transportSessionId = 7)) // Truncated module - f.packets.emit(DctPacket(0xA3.toByte(), byteArrayOf(1, 0xA4.toByte(), 1), transportSessionId = 7)) // Unknown MDB schema still ACKs - f.packets.emit(DctPacket(0xD0.toByte(), byteArrayOf(0, 1), transportSessionId = 7)) // Missing XCP item - assertEquals(listOf(0x24, 0x23, 0x50), sent.map { it.command.toInt() }) - assertEquals(listOf(0, 1, 0), sent.map { it.payload.single().toInt() }) - assertTrue(sent.all { it.transportSessionId == 7L }) - } - - private class Fixture(dispatcher: kotlinx.coroutines.CoroutineDispatcher) { - 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 - 3 -> ByteArray(6) - 8 -> byteArrayOf(subCmd, if (reject == request.command) 2 else 1) - 9 -> byteArrayOf(subCmd, if (reject == request.command) 2 else 1) - 12 -> byteArrayOf(subCmd, if (reject == request.command) 2 else 1) - else -> byteArrayOf(if (reject == request.command) 2 else 1) - } - packets.emit(DctPacket(command = (request.command + 0x80).toByte(), payload = payload)) - true - } - } - 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 - assertTrue(f.repository.connect("") is DctResult.Failure) - assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) - coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 1.toByte() }) } - } - @Test fun existingDctTestVersionCompletesHandshake() = runTest { - val f = Fixture(UnconfinedTestDispatcher(testScheduler)) - f.versionPayload = "DCT-MOBI-v1.0.0\u0000".toByteArray(Charsets.US_ASCII) - assertTrue(f.repository.connect("") is DctResult.Success) - assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) - coVerify(exactly = 1) { f.wifi.sendCommand(match { it.command == 3.toByte() }) } - coVerify(exactly = 1) { f.wifi.sendCommand(match { it.command == 4.toByte() }) } - } - @Test fun correctedDctTestVersionCompletesHandshake() = runTest { - val f = Fixture(UnconfinedTestDispatcher(testScheduler)) - val version = "DCT-MOBI-v1.0.0".toByteArray(Charsets.US_ASCII) - f.versionPayload = byteArrayOf(version.size.toByte()) + version - assertTrue(f.repository.connect("") is DctResult.Success) - assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) - } - - @Test fun initialHandshakeUsesCompleteCanAndSensorPayloads() = runTest { - val f = Fixture(UnconfinedTestDispatcher(testScheduler)) - f.repository.setInitialMeasurementConfiguration( - DctMeasurementConfiguration( - isHighSpeedCan = true, - isOldModel = true, - sensorModules = createDefaultSensorModules() - ) - ) - - assertTrue(f.repository.connect("") is DctResult.Success) - 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(), 0x00, 0x00, 0x00))) - } - @Test fun truncatedVersionStillStopsHandshakeBeforeHardwareId() = runTest { - val f = Fixture(UnconfinedTestDispatcher(testScheduler)) - f.versionPayload = byteArrayOf(15) + "DCT-MOBI-v1.0".toByteArray(Charsets.US_ASCII) - assertTrue(f.repository.connect("") is DctResult.Failure) - assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) - coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 3.toByte() }) } - } - @Test fun configurationStopsAtNegativeAck() = runTest { - val f = Fixture(UnconfinedTestDispatcher(testScheduler)) - assertTrue(f.repository.connect("") is DctResult.Success) - f.reject = 8 - val config = DctMeasurementConfiguration( - vehicleCode = "HE1", - projectName = "HE1ID", - mdbSignals = listOf(mockk(relaxed = true)) - ) - 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)) - coEvery { f.wifi.sendCommand(any()) } returns true - assertTrue(f.repository.sendCommand(DctProtocolRequests.constantWrite(0x1000, byteArrayOf(1))) is DctResult.Failure) - coVerify(exactly = 0) { f.wifi.disconnectSuspend() } - assertTrue(f.connected.value) - val command = DctProtocolRequests.constantWrite(0x1000, byteArrayOf(1)) - assertTrue(f.repository.sendCommand(command) is DctResult.Failure) - coVerify(exactly = 1) { f.wifi.sendCommand(any()) } - f.packets.emit(DctPacket((command.command + 0x80).toByte(), byteArrayOf(1))) - coEvery { f.wifi.sendCommand(any()) } coAnswers { - f.packets.emit(DctPacket((command.command + 0x80).toByte(), byteArrayOf(1))) - true - } - assertTrue(f.repository.sendCommand(command) is DctResult.Success) - } - @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 }) } 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 { - 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 }) - } coAnswers { - f.packets.emit(DctPacket(DctCommand.RES_START_TRANSMIT_DATA.code, byteArrayOf(1, 1))) - true - } - assertTrue(f.repository.startStreaming(30) is DctResult.Success) - - // Simulate a peer that accepted the STOP write but whose A1 ACK is - // delayed/lost. This must not be treated as a Wi-Fi/TCP disconnect. - coEvery { - f.wifi.sendCommand(match { it.command == DctCommand.CMD_STOP_TRANSMIT_DATA.code }) - } returns true - - assertTrue(f.repository.stopStreaming() is DctResult.Success) - coVerify(exactly = 0) { f.wifi.disconnectSuspend() } - assertTrue(f.connected.value) - assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) - assertFalse(f.repository.isStreaming.value) - } - - @Test fun restartWaitsForLateStopAckBeforeSendingStart() = runTest { - val f = Fixture(UnconfinedTestDispatcher(testScheduler)) - every { f.wifi.currentSessionId } returns 7L - assertTrue(f.repository.connect("") is DctResult.Success) - - coEvery { - f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) - } coAnswers { - f.packets.emit(DctPacket( - DctCommand.RES_START_TRANSMIT_DATA.code, - byteArrayOf(1, 1), - transportSessionId = 7L - )) - true - } - assertTrue(f.repository.startStreaming(30) is DctResult.Success) - - coEvery { - f.wifi.sendCommand(match { it.command == DctCommand.CMD_STOP_TRANSMIT_DATA.code }) - } returns true - assertTrue(f.repository.stopStreaming() is DctResult.Success) - - val restart = async { f.repository.startStreaming(30) } - runCurrent() - coVerify(exactly = 1) { - f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) - } - - f.packets.emit(DctPacket( - DctCommand.RES_STOP_TRANSMIT_DATA.code, - byteArrayOf(1), - transportSessionId = 7L - )) - runCurrent() - - assertTrue(restart.await() is DctResult.Success) - coVerify(exactly = 2) { - f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) - } - } - - @Test fun disableDoesNotWaitForUndefined8fResponse() = runTest { - val f = Fixture(UnconfinedTestDispatcher(testScheduler)) - coEvery { f.wifi.sendCommand(any()) } returns true - val before = testScheduler.currentTime - assertTrue(f.repository.sendCommand(DctProtocolRequests.disable()) is DctResult.Success) - assertEquals(before, testScheduler.currentTime) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/PcCsvTimestampTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcCsvTimestampTest.kt deleted file mode 100644 index 636a6fc..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/PcCsvTimestampTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.logger.CsvMeasurementLogger -import com.dct.app.data.logger.PcCsvColumn -import com.dct.app.data.repository.DpcProjectManager -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.MdbDatabase -import com.dct.app.domain.model.mdb.SignalDefinition -import com.dct.app.domain.model.mdb.SignalGroup -import com.dct.app.domain.model.project.DpcProject -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.presentation.main.MainUiState -import com.dct.app.presentation.main.MainViewModel -import com.dct.app.presentation.main.MeasurementRecordingState -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import org.json.JSONObject -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale - -class PcCsvTimestampTest { - @TempDir lateinit var folder: File - private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } - - @Test fun wholeSecondDisplayPreservesEverySampleAndTheDateAcrossMidnight() { - val start = Calendar.getInstance().apply { - clear(); set(2026, Calendar.OCTOBER, 6, 23, 59, 59); set(Calendar.MILLISECOND, 100) - }.timeInMillis - for (milliseconds in listOf(false, true)) { - val logger = CsvMeasurementLogger(folder, listOf("Value" to "V"), "midnight-$milliseconds.csv", - listOf(PcCsvColumn()), withMilliseconds = milliseconds) - val file = logger.start()!! - listOf(0L, 100L, 900L).forEachIndexed { index, delta -> - logger.logSample((start + delta) / 1000.0, listOf(index.toDouble())) - } - logger.stop() - val times = if (milliseconds) listOf("2026-10-06 23:59:59.100", "2026-10-06 23:59:59.200", "2026-10-07 00:00:00.000") - else listOf("2026-10-06 23:59:59", "2026-10-06 23:59:59", "2026-10-07 00:00:00") - val rows = file.readLines().drop(7) - assertEquals(3, rows.size) - rows.forEachIndexed { index, row -> assertEquals("${times[index]}, $index,${index}.000", row) } - } - } - - @Test fun existingPcTimestampKeysSurviveProjectSaveAndRestore() { - for (enabled in listOf(false, true)) for (interval in listOf(0, 1, 5)) { - val raw = JSONObject().put("csv_with_miliseconds", enabled).put("save_interval", interval) - val json = DpcProjectManager.serializeProjectToJson(DpcProject(rawJsonRoot = raw, csvWithMilliseconds = enabled, - baseFolder = folder.absolutePath, measuredFilesFolder = folder.resolve("Data").absolutePath)) - val restored = DpcProjectManager.parseProjectFromJson(json, null).rawJsonRoot!! - assertEquals(enabled, restored.getBoolean("csv_with_miliseconds")) - assertEquals(interval, restored.getInt("save_interval")) - } - } - - @Suppress("UNCHECKED_CAST") - @Test fun actualRecordingUsesCsvChoiceRegardlessOfSaveIntervalOrObsoleteTxtSetting() { - val cases = listOf("{}" to false, - "{\"csv_with_miliseconds\":false,\"save_interval\":0}" to false, - "{\"android_log_microseconds\":true}" to false, - "{\"csv_with_miliseconds\":true}" to true, - "{\"csv_with_miliseconds\":true,\"save_interval\":1}" to true, - "{\"csv_with_miliseconds\":true,\"save_interval\":0}" to true) - for ((settings, milliseconds) in cases) { - val fixture = MainViewModelTest().also { it.setUp() } - val vm = field(fixture, "viewModel").get(fixture) as MainViewModel - try { - vm.setCurrentProject(DpcProjectManager.parseProjectFromJson(settings, null).copy( - projectFileName = "CSV.dpc", mdbFileName = "fixture.mdb", - measuredFilesFolder = folder.absolutePath, writeCsv = true, writeMf4 = false)) - val signal = SignalDefinition(1, 1, "Value", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "V", 0L, 0.0, 255.0) - val ui = field(vm, "_uiState").get(vm) as MutableStateFlow - ui.value = ui.value.copy(isConnected = true, isMeasuring = true, - recordingState = MeasurementRecordingState.IDLE, - mdbDatabase = MdbDatabase("fixture.mdb", listOf(SignalGroup(1, "G", "", true, signals = listOf(signal))))) - vm.startMeasurementRecording() - assertEquals(MeasurementRecordingState.RECORDING, vm.uiState.value.recordingState) - vm.setCsvWithMilliseconds(!milliseconds) // Current file must keep its starting format. - val time = vm.uiState.value.recordingStartedAtMillis!! + 400 - val packet = DctPacket(0xA3.toByte(), byteArrayOf(1, 1, 7), receivedAtMillis = time) - MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, - Double::class.javaPrimitiveType).also { it.isAccessible = true }.invoke(vm, packet, time / 1000.0) - val logger = (field(vm, "activeCsvLoggers").get(vm) as List) - .single { it.outputFile!!.name.endsWith("_mdb.csv") } - vm.stopMeasurementRecording() - val pattern = if (milliseconds) "yyyy-MM-dd HH:mm:ss.SSS" else "yyyy-MM-dd HH:mm:ss" - val expected = SimpleDateFormat(pattern, Locale.US).format(Date(time)) - assertEquals(listOf("$expected, 0,7"), logger.outputFile!!.readLines().drop(7)) - vm.startMeasurementRecording() - assertEquals(MeasurementRecordingState.RECORDING, vm.uiState.value.recordingState) - val nextTime = vm.uiState.value.recordingStartedAtMillis!! + 123 - MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, - Double::class.javaPrimitiveType).also { it.isAccessible = true }.invoke(vm, - packet.copy(receivedAtMillis = nextTime), nextTime / 1000.0) - val nextLogger = (field(vm, "activeCsvLoggers").get(vm) as List) - .single { it.outputFile!!.name.endsWith("_mdb.csv") } - vm.stopMeasurementRecording() - val nextPattern = if (milliseconds) "yyyy-MM-dd HH:mm:ss" else "yyyy-MM-dd HH:mm:ss.SSS" - val nextExpected = SimpleDateFormat(nextPattern, Locale.US).format(Date(nextTime)) - assertEquals(listOf("$nextExpected, 0,7"), nextLogger.outputFile!!.readLines().drop(7)) - } finally { - vm.stopMeasurementRecording() - vm.scope.cancel() - fixture.tearDown() - } - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/PcDpcCompatibilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcDpcCompatibilityTest.kt deleted file mode 100644 index 7c8740d..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/PcDpcCompatibilityTest.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.repository.DpcProjectManager -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.SignalDefinition -import com.dct.app.domain.model.project.DpcProject -import org.json.JSONObject -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class PcDpcCompatibilityTest { - @Test - fun pcGraphWindowsBecomeAndroidWorkspace() { - val json = JSONObject( - """ - { - "name":"PC project", - "graphItem_property": { - "graph_linear": false, - "items":[{"ITEMTYPE":0,"RECLID":7,"INDEX":3},{"ITEMTYPE":1,"INDEX":4}] - }, - "formula_property":{"items":[{"name":"Duty","expression":"1+2"}]}, - "graphWnd_property":{"graphWndInfo":[{"index":2,"legendpos":1,"titlename":"Engine", "items":[ - {"item_type":0,"item_reclID":7,"item_index":3,"item_name":"RPM","item_hideshow":false,"item_thickness":4,"item_dashstyle":2}, - {"item_type":1,"item_index":4}, - {"item_type":2,"item_smindex":0,"item_chindex":1}, - {"item_type":3,"item_index":0}, - {"item_type":4,"item_index":4096,"item_name":"Battery"} - ]}]}, - "multiXgraphWnd_property":{"multiXgraphWndInfo":[{"index":5,"legendpos":2,"titlename":"Multi", "items":[ - {"item_type":0,"item_reclID":7,"item_index":3} - ]}]} - } - """.trimIndent() - ) - - val project = DpcProjectManager.parseProjectFromJson(json.toString(), null) - val workspace = project.workspace - assertNotNull(workspace) - assertEquals("pc_page_1", workspace!!.activePageId) - assertEquals(2, workspace.pages.first().windows.size) - - val graph = workspace.pages.first().windows.first() - assertEquals("GRAPH", graph.type) - assertEquals("Engine", graph.title) - assertEquals( - listOf("sig_7_3", "can_4", "sm_01_02", "formula_Duty", "a2l_4096"), - graph.signals.map { it.signalId } - ) - assertFalse(graph.signals.first().isVisible) - assertEquals(4f, graph.signals.first().lineWidthDp) - assertEquals(AxisPosition.LEFT_0, graph.signals.first().axisPosition) - assertEquals(AxisPosition.LEFT_0, graph.signals[1].axisPosition) - assertEquals(AxisPosition.LEFT_0, graph.signals[4].axisPosition) - assertEquals(AxisPosition.LEFT_0, workspace.pages.first().windows[1].signals.first().axisPosition) - assertEquals(false, project.graphLinear) - } - - @Test - fun pcTileWindowMapsToAlphanumericalAndRoundTrips() { - val json = JSONObject( - """ - { - "name":"PC tiles", - "tileWnd_property":{"tileWndInfo":[{"index":3,"istilemode":false,"titlename":"Live values", "items":[ - {"item_type":0,"item_reclID":7,"item_index":3,"item_name":"RPM","item_hideshow":false,"item_tilecolor":16711935}, - {"item_type":1,"item_index":4,"item_tilecolor":255} - ]}]} - } - """.trimIndent() - ) - val parsed = DpcProjectManager.parseProjectFromJson(json.toString(), null) - val tile = parsed.workspace!!.pages.single().windows.single() - assertEquals("ALPHANUMERICAL", tile.type) - assertEquals(listOf("sig_7_3", "can_4"), tile.targetSignals) - assertEquals(setOf("sig_7_3"), tile.hiddenSignalIds) - assertEquals(false, tile.isTileMode) - - val saved = JSONObject(DpcProjectManager.serializeProjectToJson(parsed)) - .getJSONObject("tileWnd_property").getJSONArray("tileWndInfo").getJSONObject(0) - assertEquals("Live values", saved.getString("titlename")) - assertEquals(false, saved.getBoolean("istilemode")) - assertEquals(2, saved.getJSONArray("items").length()) - assertEquals(0, saved.getJSONArray("items").getJSONObject(0).getInt("item_type")) - assertEquals(7, saved.getJSONArray("items").getJSONObject(0).getInt("item_reclID")) - } - - @Test - fun androidWorkspaceIsExportedUsingPcGraphKeys() { - val signal = SignalDefinition( - reclid = 7, idx = 3, name = "RPM", attr = MdbAttrType.ATTR_2_UINT16, - cvsA = 1.0, cvsB = 0.0, unit = "rpm", color = 0x00112233L, - min = 0.0, max = 8000.0 - ) - val source = JSONObject( - """ - {"name":"PC", "graphWnd_property":{"vendor":true}, - "multiXgraphWnd_property":{"vendor":true}, - "graphItem_property":{"graph_linear":true,"vendor":7}} - """.trimIndent() - ) - val project = DpcProjectManager.parseProjectFromJson(source.toString(), null).copy( - graphLinear = true, - mdbDatabase = com.dct.app.domain.model.mdb.MdbDatabase( - groups = listOf(com.dct.app.domain.model.mdb.SignalGroup(7, "7", "", true, signals = listOf(signal))) - ) - ) - val workspace = com.dct.app.domain.model.project.DpcWorkspaceState( - activePageId = "page", - pages = listOf( - com.dct.app.domain.model.project.DpcPageState( - id = "page", title = "Page", - windows = listOf( - com.dct.app.domain.model.project.DpcWindowState( - id = "pc_graph_2", type = "GRAPH", title = "Engine", - signals = listOf( - com.dct.app.domain.model.project.DpcSignalBindingState( - "sig_7_3", "RPM", AxisPosition.LEFT_0, 0x00112233L, - unit = "rpm", isVisible = false, lineWidthDp = 4f, dashStyle = 2 - ) - ) - ), - com.dct.app.domain.model.project.DpcWindowState( - id = "pc_multi_5", type = "MULTI_AXIS_GRAPH", title = "Multi" - ) - ) - ) - ) - ) - - val serialized = JSONObject( - DpcProjectManager.serializeProjectToJson(project.copy(workspace = workspace)) - ) - val graph = serialized.getJSONObject("graphWnd_property") - .getJSONArray("graphWndInfo").getJSONObject(0) - assertEquals(2, graph.getInt("index")) - assertEquals("Engine", graph.getString("titlename")) - val item = graph.getJSONArray("items").getJSONObject(0) - assertEquals(0, item.getInt("item_type")) - assertEquals(7, item.getInt("item_reclID")) - assertEquals(3, item.getInt("item_index")) - assertFalse(item.getBoolean("item_hideshow")) - assertEquals(4, item.getInt("item_thickness")) - assertEquals(2, item.getInt("item_dashstyle")) - assertEquals(1, serialized.getJSONObject("multiXgraphWnd_property") - .getJSONArray("multiXgraphWndInfo").length()) - val global = serialized.getJSONObject("graphItem_property").getJSONArray("items") - assertEquals(1, global.length()) - assertEquals(7, global.getJSONObject(0).getInt("RECLID")) - assertEquals(3, global.getJSONObject(0).getInt("INDEX")) - assertTrue(serialized.has("android_workspace")) - assertTrue(serialized.getJSONObject("graphWnd_property").getBoolean("vendor")) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/PcMf4CompatibilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcMf4CompatibilityTest.kt deleted file mode 100644 index fc1e574..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/PcMf4CompatibilityTest.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.logger.Mf4MeasurementLogger -import com.dct.app.data.logger.PcMf4Channel -import com.dct.app.data.parser.A2lParser -import com.dct.app.domain.model.a2l.A2lDataType -import com.dct.app.domain.model.mdb.MdbAttrType -import com.dct.app.domain.model.mdb.MdbDatabase -import com.dct.app.domain.model.mdb.SignalDefinition -import com.dct.app.domain.model.mdb.SignalGroup -import com.dct.app.domain.model.project.DpcProject -import com.dct.app.domain.model.protocol.DctPacket -import com.dct.app.presentation.main.MainUiState -import com.dct.app.presentation.main.MainViewModel -import com.dct.app.presentation.main.MeasurementRecordingState -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import org.json.JSONObject -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Assumptions.assumeTrue -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.zip.Inflater - -class PcMf4CompatibilityTest { - @TempDir lateinit var folder: File - private fun bytes(file: File) = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN) - private fun export(file: File) { - System.getenv("DCT_PC_MF4_INPUT")?.let { root -> file.copyTo(File(root, file.name), overwrite = true) } - } - - private fun records(file: File): ByteBuffer { - val fileBytes = bytes(file) - val size = fileBytes.getInt(232 + 96) - val hl = fileBytes.getLong(168 + 40).toInt() - assertEquals("##HL", String(fileBytes.array(), hl, 4)) - assertEquals(1, fileBytes.get(hl + 34).toInt()) - var dl = fileBytes.getLong(hl + 24).toInt() - val output = java.io.ByteArrayOutputStream() - while (dl != 0) { - val dz = fileBytes.getLong(dl + 32).toInt() - assertEquals("##DZ", String(fileBytes.array(), dz, 4)) - assertEquals(size, fileBytes.getInt(dz + 28)) - val rawSize = fileBytes.getLong(dz + 32).toInt() - val zipSize = fileBytes.getLong(dz + 40).toInt() - val transposed = ByteArray(rawSize) - val inflater = Inflater() - try { - inflater.setInput(fileBytes.array(), dz + 48, zipSize) - var read = 0 - while (!inflater.finished() && read < rawSize) { - val amount = inflater.inflate(transposed, read, rawSize - read) - assertTrue(amount > 0, "Truncated compressed record data") - read += amount - } - assertEquals(rawSize, read) - } finally { inflater.end() } - val rows = rawSize / size - val raw = ByteArray(rawSize) - for (column in 0 until size) for (row in 0 until rows) raw[row * size + column] = transposed[column * rows + row] - output.write(raw) - dl = fileBytes.getLong(dl + 24).toInt() - } - return ByteBuffer.wrap(output.toByteArray()).order(ByteOrder.LITTLE_ENDIAN) - } - - @Test fun rawSignedWidthsAndConversionDoNotRoundTripThroughPhysicalDouble() { - val channels = listOf(PcMf4Channel("U8", dataType=0, bitCount=8), - PcMf4Channel("S16", "°C", 2, 16, factor=.1, offset=-40.0, description="sensor", - sourceName="sensor", sourcePath="ECU.sensor"), - PcMf4Channel("U64", dataType=0, bitCount=64), PcMf4Channel("F32", bitCount=32)) - val logger = Mf4MeasurementLogger(folder, channels.map { it.name to it.unit }, "raw.mf4", channels) - val file = logger.start(1000)!! - logger.logRawSampleAtMillis(1125, listOf(255, -134, -1L, 3.75)) - logger.logRawSampleAtMillis(1500, listOf(null, null, null, null)) - logger.stop() - val record = records(file) - assertEquals(23, bytes(file).getInt(232 + 96)) - assertEquals(.125, record.double) - assertEquals(255, record.get().toInt() and 255) - assertEquals(-134, record.short.toInt()) - assertEquals(-1L, record.long) // All 64 uint bits survive without Double conversion. - assertEquals(3.75f, record.float) - assertEquals(.5, record.double) - assertEquals(255, record.get().toInt() and 255) - assertEquals(-134, record.short.toInt()) // PC preserves previous values for other messages. - assertEquals(-1L, record.long) - assertEquals(3.75f, record.float) - val data = bytes(file) - assertEquals(9, data.getShort(64 + 80).toInt()) - assertTrue(data.getLong(64 + 32) > 0) - assertTrue(data.getLong(64 + 64) > 0) - val secondCn = data.getLong(336 + 24).toInt() - val thirdCn = data.getLong(secondCn + 24).toInt() - assertTrue(data.getLong(thirdCn + 48) > 0) - assertTrue(data.getLong(thirdCn + 56) > 0) - } - - @Test fun productionA2lDefinitionsPreserveNativeTypesUnitsSourceAndNonlinearConversions() { - val base = SignalDefinition(0, 1, "Rational", MdbAttrType.ATTR_3_INT16, 1.0, 0.0, "C", 0L, -100.0, 100.0, - description = "ECU measurement") - val measurement = A2lParser.A2lMeasurement("Rational", unit = "degC", symbolLink = "ECU.Rational") - for (type in A2lDataType.entries) { - val definition = PcMf4Channel.a2l(base.copy(a2lDataType = type), measurement to null) - assertEquals(if (type.isFloat) 4 else if (type.isSigned) 2 else 0, definition.dataType) - assertEquals(if (type == A2lDataType.FLOAT16_IEEE) 32 else type.byteSize * 8, definition.bitCount) - assertEquals("degC", definition.unit) // MF4 retains the original unit, not the UI alias. - assertEquals("ECU.Rational", definition.sourcePath) - assertEquals(-100.0 to 100.0, definition.rawRange) - } - val rational = A2lParser.CompuMethod("R", conversionType = "RAT_FUNC", - cvsA = 1.0, cvsB = 2.0, cvsC = 3.0, cvsD = 0.0, cvsE = 0.0, cvsF = 1.0) - val table = A2lParser.CompuMethod("T", conversionType = "TAB_INTP", tabIntpPoints = listOf(0.0 to 0.0, 10.0 to 100.0)) - val channels = listOf(PcMf4Channel.a2l(base.copy(a2lDataType = A2lDataType.SWORD), measurement to rational), - PcMf4Channel.a2l(base.copy(name = "Table", a2lDataType = A2lDataType.SWORD), - measurement.copy(name = "Table", symbolLink = "ECU.Table") to table)) - assertEquals(listOf(2, 4), channels.map { it.conversionType }) - val logger = Mf4MeasurementLogger(folder, channels.map { it.name to it.unit }, "conversions-pc.mf4", channels) - val file = logger.start(1000)!! - logger.logRawSampleAtMillis(1000, listOf(2, 2)) - logger.logRawSampleAtMillis(1125, listOf(5, 5)) - logger.stop() - export(file) // Independent reader verifies CC evaluation, not only the parameter bytes. - } - - @Test fun compressedChunksStayBoundedAndFollowTheDataListChain() { - val channels = listOf(PcMf4Channel("Count", dataType=0, bitCount=16)) - val logger = Mf4MeasurementLogger(folder, listOf("Count" to ""), "chunks.mf4", channels) - val file = logger.start(1000)!! - repeat(10_003) { logger.logRawSampleAtMillis(1000L + it, listOf(it)) } - logger.stop(); logger.stop() - val raw = records(file) - assertEquals(10_003 * 10, raw.remaining()) - repeat(10_003) { assertEquals(it / 1000.0, raw.double); assertEquals(it, raw.short.toInt() and 65535) } - assertEquals(10_003L, bytes(file).getLong(232 + 80)) - export(file) - } - - @Test fun emptyCompressedFileAndRestartRemainFinalized() { - val channel = PcMf4Channel("Value", dataType=2, bitCount=16) - val logger = Mf4MeasurementLogger(folder, listOf("Value" to ""), "empty-pc.mf4", listOf(channel)) - val file = logger.start(1000)!! - logger.stop(); assertEquals(0, records(file).remaining()); export(file) - logger.start(2000) - logger.logRawSampleAtMillis(1990, listOf(-2)) // Preserve the previously fixed queued ingress case. - logger.stop() - val raw = records(file) - assertEquals(-.01, raw.double); assertEquals(-2, raw.short.toInt()) - assertEquals(1L, bytes(file).getLong(232 + 80)) - export(file.copyTo(File(folder, "queued-pc.mf4"))) - } - - @Test fun newRecordingSeedsLiveRawStateAndAnExplicitNanDoesNotKeepAnOldFormulaValue() { - val channels = listOf(PcMf4Channel("Raw", dataType=0, bitCount=16), - PcMf4Channel("Formula", bitCount=32, physicalInput=true)) - val logger = Mf4MeasurementLogger(folder, channels.map { it.name to it.unit }, "seeded-pc.mf4", channels) - val file = logger.start(1000, mapOf("Raw" to 65535), mapOf("Formula" to 1.5))!! - logger.logRawSampleAtMillis(1000, listOf(null, null)) - logger.logRawSampleAtMillis(1125, listOf(null, Double.NaN)) - logger.logRawSampleAtMillis(1500, listOf(null, null)) - logger.stop() - val raw = records(file) - assertEquals(0.0, raw.double); assertEquals(65535, raw.short.toInt() and 65535); assertEquals(1.5f, raw.float) - assertEquals(.125, raw.double); assertEquals(65535, raw.short.toInt() and 65535); assertTrue(raw.float.isNaN()) - assertEquals(.5, raw.double); assertEquals(65535, raw.short.toInt() and 65535); assertTrue(raw.float.isNaN()) - } - - @Suppress("UNCHECKED_CAST") - @Test fun realRecordingStartAndRestartKeepLiveRawValuesFromOtherMdbMessages() { - fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } - val fixture = MainViewModelTest().also { it.setUp() } - val vm = field(fixture, "viewModel").get(fixture) as MainViewModel - val process = MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, - Double::class.javaPrimitiveType).also { it.isAccessible = true } - val close = MainViewModel::class.java.getDeclaredMethod("closeMeasurementLoggers").also { it.isAccessible = true } - fun receive(recordId: Int, value: Int, time: Long) { - val payload = if (recordId == 1) byteArrayOf(1, 1, value.toByte()) - else byteArrayOf(1, 2, (value ushr 8).toByte(), value.toByte()) - process.invoke(vm, DctPacket(0xA3.toByte(), payload, receivedAtMillis = time), time / 1000.0) - } - try { - vm.setCurrentProject(DpcProject(mdbFileName = "fixture.mdb", projectFileName = "MID.dpc", - measuredFilesFolder = folder.absolutePath, writeCsv = false, writeMf4 = true)) - val a = SignalDefinition(1, 1, "A", MdbAttrType.ATTR_0_UINT8, 2.0, -10.0, "V", 0L, 0.0, 255.0) - val b = SignalDefinition(2, 2, "B", MdbAttrType.ATTR_2_UINT16, .1, -40.0, "C", 0L, 0.0, 65535.0) - val ui = field(vm, "_uiState").get(vm) as MutableStateFlow - ui.value = ui.value.copy(isConnected = true, isMeasuring = true, - recordingState = MeasurementRecordingState.IDLE, - mdbDatabase = MdbDatabase("fixture.mdb", listOf( - SignalGroup(1, "A", "", true, signals = listOf(a)), - SignalGroup(2, "B", "", true, signals = listOf(b))))) - receive(1, 12, System.currentTimeMillis()) - receive(2, 500, System.currentTimeMillis()) - repeat(2) { recording -> - vm.startMeasurementRecording() - assertEquals(MeasurementRecordingState.RECORDING, vm.uiState.value.recordingState) - val start = vm.uiState.value.recordingStartedAtMillis!! - // Only one message arrives in each file. The other channel must come from live raw state. - receive(if (recording == 0) 1 else 2, if (recording == 0) 20 else 600, start + 125) - val logger = (field(vm, "activeMf4Loggers").get(vm) as List) - .single { it.outputFile!!.name.endsWith("_mdb.mf4") } - vm.stopMeasurementRecording() - assertEquals(MeasurementRecordingState.IDLE, vm.uiState.value.recordingState) - val raw = records(logger.outputFile!!) - assertEquals(16, raw.remaining()) // Exactly one sample: Time + 2 signed32 channels. - assertEquals(.125, raw.double) - assertEquals(20, raw.int) // Not the physical value 30, including on restart. - assertEquals(if (recording == 0) 500 else 600, raw.int) // Not physical 10/20. - } - } finally { - close.invoke(vm) - vm.scope.cancel() - fixture.tearDown() - } - } - - @Test fun wideTypedRecordingUsesBoundedCompressedChunksWithoutDroppingTailChannels() { - val channels = (0 until 4939).map { PcMf4Channel("DBC_$it", dataType=0, bitCount=32) } - val logger = Mf4MeasurementLogger(folder, channels.map { it.name to it.unit }, "wide-pc.mf4", channels) - val file = logger.start(1000)!! - repeat(400) { logger.logRawSampleAtMillis(1000L + it, listOf(it)) } - logger.stop() - val record = records(file) - assertEquals(19764, bytes(file).getInt(232 + 96)) - repeat(400) { row -> - assertEquals(row / 1000.0, record.double) - assertEquals(row, record.int) - repeat(4938) { assertEquals(0, record.int) } - } - export(file) - } - - @Test fun regenerateCommonInputForIndependentPcDllComparison() { - val path = System.getenv("DCT_PC_MF4_INPUT") - assumeTrue(path != null, "Optional PC raw-input artifacts for independent comparison") - val root = File(path!!) - for (name in listOf("reference", "synthetic")) { - val input = JSONObject(File(root, "$name.json").readText()) - val definitions = input.getJSONArray("channels") - val channels = (1 until definitions.length()).map { index -> - val c = definitions.getJSONObject(index) - val source = c.optJSONObject("source") - val conversion = c.optJSONObject("conversion") - val parameters = conversion?.getJSONArray("parameters") - PcMf4Channel(c.getString("name"), c.getString("unit"), c.getInt("data_type"), c.getInt("bit_count"), - factor=parameters?.getDouble(1) ?: 1.0, offset=parameters?.getDouble(0) ?: 0.0, - description=c.getString("description"), sourceName=source?.getString("name"), - sourcePath=source?.getString("path").orEmpty(), sourceComment=source?.getString("comment").orEmpty(), - rawRange=if(c.getInt("flags") and 8 != 0) c.getDouble("min_raw") to c.getDouble("max_raw") else null) - } - val logger = Mf4MeasurementLogger(root, channels.map { it.name to it.unit }, "android-$name.mf4", channels) - val start = input.getLong("start_ns") / 1_000_000L - val file = logger.start(start)!! - val samples = input.getJSONArray("records") - repeat(samples.length()) { index -> - val row = samples.getJSONArray(index) - logger.logRawSampleAtMillis(start + Math.round(row.getDouble(0) * 1000), - (1 until row.length()).map { if (row.isNull(it)) null else row.get(it) as Number }) - } - logger.stop() - assertEquals(input.getInt("record_size"), bytes(file).getInt(232 + 96)) - assertEquals(samples.length().toLong(), bytes(file).getLong(232 + 80)) - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt deleted file mode 100644 index 7bd98b3..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt +++ /dev/null @@ -1,189 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.communication.WifiSocketManager -import com.dct.app.data.logger.PcOperationalLogger -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.domain.model.protocol.DctPacket -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.* -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.net.ServerSocket -import java.text.SimpleDateFormat -import java.util.Locale - -@OptIn(ExperimentalCoroutinesApi::class) -class PcOperationalLoggerTest { - @TempDir lateinit var folder: File - private val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) - .parse("2026-10-01 12:34:56.789")!!.time - private val codec = DctPacketCodec() - private val packet = DctPacket(0xA3.toByte(), byteArrayOf(0, 1, 0xFF.toByte())) - private fun files(extension: String) = folder.listFiles()!!.filter { it.extension == extension } - private fun decodeLine(line: String): ByteArray = line.substringAfter("] ") - .trim().split(' ').map { it.toInt(16).toByte() }.toByteArray() - - @Test fun actionCsvHasPcHeadersEscapingAndDrainsOnClose() = runTest { - val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) - logger.action("Open project file A,\"B\".dpc\nOK") - logger.close() - logger.action("must not be written") - logger.awaitClosed() - val file = files("csv").single() - assertEquals("2026-10-01_12-34-56_log.csv", file.name) - val csv = file.readText() - assertTrue(csv.startsWith("File: ${file.name}\r\nDate: 2026-10-01\r\n\r\n Time, Description,\r\n")) - assertTrue(csv.contains("12:34:56, Start DCT Program,")) - assertTrue(csv.contains("\"Open project file A,\"\"B\"\".dpc OK\"")) - assertTrue(csv.contains("Terminate DCT Program")) - assertFalse(csv.contains("must not")) - } - - @Test fun fullFramesAndIngressMillisecondsSurviveCallerMutation() = runTest { - val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) - val bytes = codec.encode(packet) - logger.connected(1, "PC") - logger.frame(1, "Recv", bytes, time + 10) - logger.frame(1, "Send", bytes, time + 11) - bytes.fill(0) - logger.disconnected(1) - logger.close(); logger.awaitClosed() - val file = files("txt").single() - assertEquals("20261001_123456_log.txt", file.name) - val lines = file.readLines() - assertTrue(lines[0].startsWith("[Recv][12:34:56.799] A1 ")) - assertTrue(lines[1].startsWith("[Send][12:34:56.800] A1 ")) - lines.forEach { assertEquals(packet, codec.decode(decodeLine(it))) } - } - - @Test fun noPacketCreatesNoTxtAndSameSecondConnectionsNeverOverwrite() = runTest { - val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) - logger.connected(1, "empty"); logger.disconnected(1) - logger.connected(2, "first"); logger.frame(2, "Recv", codec.encode(packet)) - logger.disconnected(2) - logger.connected(3, "second") - logger.frame(2, "Recv", codec.encode(packet)) // Stale writer after reconnect. - logger.disconnected(2) // Must not close connection 3. - logger.frame(3, "Send", codec.encode(packet)) - logger.close(); logger.awaitClosed() - assertEquals(2, files("txt").size) - assertEquals(setOf("20261001_123456_log.txt", "20261001_123456_log-01.txt"), files("txt").map { it.name }.toSet()) - assertEquals(2, files("txt").sumOf { it.readLines().size }) - } - - @Test fun fixedPcMillisecondsKeepEveryPacketInOrderEvenWithEqualTimestamps() = runTest { - val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) - logger.connected(1, "PC") - val expected = (1..4).map { DctPacket(0xA3.toByte(), byteArrayOf(it.toByte())) } - logger.frame(1, "Recv", codec.encode(expected[0]), time) - logger.frame(1, "Recv", codec.encode(expected[1]), time) - logger.frame(1, "Recv", codec.encode(expected[2]), time) - logger.frame(1, "Send", codec.encode(expected[3]), time + 211) - logger.close(); logger.awaitClosed() - val lines = files("txt").single().readLines() - assertEquals(4, lines.size) - assertTrue(lines[0].startsWith("[Recv][12:34:56.789]")) - assertTrue(lines[1].startsWith("[Recv][12:34:56.789]")) - assertTrue(lines[2].startsWith("[Recv][12:34:56.789]")) - assertTrue(lines[3].startsWith("[Send][12:34:57.000]")) - assertEquals(expected, lines.map { codec.decode(decodeLine(it)) }) - } - - @Test fun sameSecondAppStartsPreservePreviousCsv() = runTest { - repeat(2) { - val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) - logger.action("run $it"); logger.close(); logger.awaitClosed() - } - assertEquals(2, files("csv").size) - assertTrue(files("csv").any { it.readText().contains("run 0") }) - assertTrue(files("csv").any { it.readText().contains("run 1") }) - } - - @Test fun liveRawLogFlushesWithoutStoppingMeasurement() = runTest { - val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) - logger.connected(1, "PC"); logger.frame(1, "Recv", codec.encode(packet)) - runCurrent() - advanceTimeBy(1001); runCurrent() - assertTrue(files("txt").single().readText().contains("[Recv][12:34:56.789]")) - assertFalse(files("csv").single().readText().contains("Terminate")) - logger.close(); logger.awaitClosed() - } - - @Test fun boundedQueueExplicitlyMarksIncompleteAndResumesOnNextConnection() = runTest { - val errors = mutableListOf() - val bytes = codec.encode(packet) - val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }, errors::add, bytes.size) - logger.connected(1, "PC") - logger.frame(1, "Recv", bytes) - logger.frame(1, "Recv", bytes) // Disk worker has not run; capacity exhausted. - logger.frame(1, "Recv", bytes) - runCurrent() - logger.disconnected(1); logger.connected(2, "PC") - logger.frame(2, "Recv", bytes) - logger.close(); logger.awaitClosed() - assertEquals(1, errors.size) - assertTrue(files("csv").single().readText().contains("Communication log incomplete")) - assertTrue(files("txt").any { it.readText().contains("# Communication log incomplete") }) - assertEquals(2, files("txt").sumOf { it.readLines().count { line -> line.startsWith("[Recv]") } }) - } - - @Test fun storageFailureIsReportedWithoutThrowingIntoCommunication() = runTest { - val errors = mutableListOf() - val notDirectory = File(folder, "not-a-folder").apply { writeText("preserve") } - val logger = PcOperationalLogger({ notDirectory }, StandardTestDispatcher(testScheduler), { time }, errors::add) - logger.connected(1, "PC"); logger.frame(1, "Recv", codec.encode(packet)) - logger.frame(1, "Send", codec.encode(packet)) - logger.close(); logger.awaitClosed() - assertEquals(2, errors.size) // CSV and first TXT; no diagnostic file writer. - assertEquals("preserve", notDirectory.readText()) - } - - @Test fun socketLogsActualTxAndValidatedFragmentedRxBeforeBusinessFiltering() = runBlocking { - val logger = PcOperationalLogger({ folder }) - val socketManager = WifiSocketManager(operationalLogger = logger) - ServerSocket(0).use { server -> - try { - assertFalse(socketManager.sendCommand(packet)) // No socket: no fictional TX entry. - val connected = CompletableDeferred() - socketManager.connect("127.0.0.1", server.localPort, - onSuccess = { connected.complete(Unit) }, - onFailure = { connected.completeExceptionally(IllegalStateException(it)) }) - withContext(Dispatchers.IO) { server.accept() }.use { peer -> - peer.soTimeout = 3000 - withTimeout(5000) { connected.await() } - assertEquals(com.dct.app.data.communication.WifiApEndpoint("127.0.0.1", server.localPort), - socketManager.connectedEndpoint.value) - val received = async(start = CoroutineStart.UNDISPATCHED) { socketManager.packetStream.first() } - val frame = codec.encode(packet) - val corrupt = frame.copyOf().apply { this[lastIndex - 1] = 0 } - peer.getOutputStream().write(corrupt + frame.copyOfRange(0, 3)) - withTimeout(5000) { - while (socketManager.metrics.tcpBytesRead.get() < corrupt.size + 3L) delay(10) - } - assertFalse(received.isCompleted, "A partial/corrupt frame must not be published") - peer.getOutputStream().write(frame.copyOfRange(3, frame.size)) - assertEquals(packet, withTimeout(5000) { received.await() }) - assertTrue(socketManager.sendCommand(packet)) - assertArrayEquals(frame, withContext(Dispatchers.IO) { peer.getInputStream().readNBytes(frame.size) }) - socketManager.invalidateConnection() - assertNull(socketManager.connectedEndpoint.value) - } - } finally { - socketManager.close(); logger.close(); withTimeout(5000) { logger.awaitClosed() } - } - } - val lines = files("txt").single().readLines() - assertEquals(1, lines.count { it.startsWith("[Recv]") }) - assertEquals(1, lines.count { it.startsWith("[Send]") }) - lines.forEach { assertTrue(it.matches(Regex("\\[(Recv|Send)]\\[\\d{2}:\\d{2}:\\d{2}\\.\\d{3}] .*"))) } - lines.forEach { assertEquals(packet, codec.decode(decodeLine(it))) } - val csv = files("csv").single().readText() - assertTrue(csv.contains("Connected 127.0.0.1")) - assertTrue(csv.contains("Disconnected")) - assertTrue(files("rxdiag").isEmpty(), "TCP sessions must no longer create .rxdiag files") - assertTrue(folder.listFiles()!!.all { it.extension in setOf("txt", "csv") }) - } -} 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 deleted file mode 100644 index 33296d3..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/RecordingElapsedTimeTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -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/SMLinEvaluatorTest.kt b/Android/app/src/test/java/com/dct/app/tests/SMLinEvaluatorTest.kt deleted file mode 100644 index fc6c116..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/SMLinEvaluatorTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.formula.FormulaEvaluator -import com.dct.app.core.formula.SMLinEvaluator -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -class SMLinEvaluatorTest { - - @Test - @DisplayName("기본 2점 선형 보간 (0V -> 0bar, 5V -> 100bar) 계산 검증") - fun testTwoPointLinearInterpolation() { - val vars = mapOf("01-01 ch" to 2.5) - val res = SMLinEvaluator.evaluate("LIN(\"01-01\"; 0.0; 0.0; 5.0; 100.0)", vars) - assertEquals(50.0, res, 0.001) - } - - @Test - @DisplayName("다중 구간(Piecewise) 선형 보간 검증") - fun testPiecewiseLinearInterpolation() { - val expr = "LIN(\"01-02\"; 0.0; 0.0; 2.0; 20.0; 4.0; 60.0; 10.0; 100.0)" - - assertEquals(10.0, SMLinEvaluator.evaluate(expr, mapOf("01-02 ch" to 1.0)), 0.001) - assertEquals(40.0, SMLinEvaluator.evaluate(expr, mapOf("01-02 ch" to 3.0)), 0.001) - assertEquals(80.0, SMLinEvaluator.evaluate(expr, mapOf("01-02 ch" to 7.0)), 0.001) - } - - @Test - @DisplayName("범위 외 값(최솟값 미만 / 최댓값 초과) 클램핑 검증") - fun testOutOfRangeClamping() { - val expr = "LIN(\"01-01\"; 1.0; 10.0; 5.0; 50.0)" - - // 최솟값 미만 -> y1 반환 - assertEquals(10.0, SMLinEvaluator.evaluate(expr, mapOf("01-01 ch" to -5.0)), 0.001) - assertEquals(10.0, SMLinEvaluator.evaluate(expr, mapOf("01-01 ch" to 1.0)), 0.001) - - // 최댓값 초과 -> yn 반환 - assertEquals(50.0, SMLinEvaluator.evaluate(expr, mapOf("01-01 ch" to 5.0)), 0.001) - assertEquals(50.0, SMLinEvaluator.evaluate(expr, mapOf("01-01 ch" to 10.0)), 0.001) - } - - @Test - @DisplayName("채널명 별칭(\"01-01\", \"01-01 ch\", \"sm_01_01\") 식별 검증") - fun testChannelAliases() { - val vars = mapOf("sm_02_05" to 3.0) - val res = SMLinEvaluator.evaluate("LIN(\"02-05\"; 0.0; 0.0; 6.0; 120.0)", vars) - assertEquals(60.0, res, 0.001) - } - - @Test - @DisplayName("FormulaEvaluator와 LIN 수식 결합 평가 검증") - fun testFormulaEvaluatorIntegration() { - val telemetry = mapOf("01-01 ch" to 2.5) - val res = FormulaEvaluator.evaluate("LIN(\"01-01\"; 0.0; 0.0; 5.0; 100.0)", telemetry) - assertEquals(50.0, res, 0.001) - - assertTrue(FormulaEvaluator.validate("LIN(\"01-01\"; 0.0; 0.0; 5.0; 100.0)")) - } - - @Test - @DisplayName("잘못된 문법 및 미연결 채널에 대한 NaN 반환 검증") - fun testInvalidExpressionHandling() { - assertEquals(Double.NaN, SMLinEvaluator.evaluate("LIN()", emptyMap())) - assertEquals(Double.NaN, SMLinEvaluator.evaluate("LIN(\"01-01\"; 1.0)", emptyMap())) - assertEquals(Double.NaN, SMLinEvaluator.evaluate("LIN(\"01-01\"; 1.0; 2.0)", mapOf("01-01 ch" to 1.5))) // 점이 1개뿐 - assertEquals(Double.NaN, SMLinEvaluator.evaluate("LIN(\"01-01\"; 1.0; 2.0; 3.0; 4.0)", emptyMap())) // 변수 없음 - assertTrue(SMLinEvaluator.evaluate("LIN(\"01-01\", 0.0, 0.0, 5.0, 100.0)", mapOf("01-01 ch" to 2.5)).isNaN()) - assertTrue(SMLinEvaluator.evaluate("LIN(\"01-01\"; 5.0; 100.0; 0.0; 0.0)", mapOf("01-01 ch" to 2.5)).isNaN()) - assertTrue(SMLinEvaluator.evaluate("LIN(\"01-01\"; 1.0; 10.0; 1.0; 20.0)", mapOf("01-01 ch" to 1.0)).isNaN()) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/SensorModuleIntegrationTest.kt b/Android/app/src/test/java/com/dct/app/tests/SensorModuleIntegrationTest.kt deleted file mode 100644 index d1bf0e7..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/SensorModuleIntegrationTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.logger.CsvMeasurementLogger -import com.dct.app.data.logger.Mf4MeasurementLogger -import com.dct.app.data.repository.DpcProjectManager -import com.dct.app.domain.model.project.DpcProject -import com.dct.app.domain.model.project.DpcSensorModuleChannel -import com.dct.app.domain.model.project.DpcSensorModuleConfig -import com.dct.app.domain.model.project.createDefaultSensorModules -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File - -class SensorModuleIntegrationTest { - - @Test - @DisplayName("DpcProject JSON 직렬화/역직렬화 시 4개 센서 모듈 및 64개 채널 속성이 100% 보존되어야 한다") - fun testSensorModuleProjectSerialization() { - val modules = createDefaultSensorModules().toMutableList() - // SM 1 채널 1: 전압 센서 - val ch1 = DpcSensorModuleChannel( - channelIndex = 0, - name = "01-01 ch", - isVoltage = true, - unit = "V", - description = "배터리 모니터링 전압", - color = 0xFFFF5722L - ) - val chList = modules[0].channels.toMutableList() - chList[0] = ch1 - modules[0] = modules[0].copy(isUsed = true, channels = chList) - - // SM 2 활성화 - modules[1] = modules[1].copy(isUsed = true) - - val project = DpcProject( - projectFileName = "SM_TEST_PROJECT.dpc", - sensorModules = modules, - smConfigured = true - ) - - val json = DpcProjectManager.serializeProjectToJson(project) - assertTrue(json.contains("01-01 ch")) - assertTrue(json.contains("배터리 모니터링 전압")) - - val parsed = DpcProjectManager.parseProjectFromJson(json, null) - assertEquals(4, parsed.sensorModules.size) - assertTrue(parsed.sensorModules[0].isUsed) - assertTrue(parsed.sensorModules[1].isUsed) - assertFalse(parsed.sensorModules[2].isUsed) - - val parsedCh1 = parsed.sensorModules[0].channels[0] - assertEquals("01-01 ch", parsedCh1.name) - assertTrue(parsedCh1.isVoltage) - assertEquals("V", parsedCh1.unit) - assertEquals("배터리 모니터링 전압", parsedCh1.description) - } - - @Test - @DisplayName("Sensor Module 계측 데이터가 실시간 CSV 및 MF4 로거 파일에 _sm 접미사로 기록되어야 한다") - fun testSensorModuleLogging(@TempDir tempDir: File) { - val smHeaders = listOf("01-01 ch" to "'C", "01-02 ch" to "V") - val csvName = "SM_PROJ_2026-08-27_14-00-00_sm.csv" - val mf4Name = "SM_PROJ_2026-08-27_14-00-00_sm.mf4" - - val csvLogger = CsvMeasurementLogger(tempDir, smHeaders, csvName) - val csvFile = csvLogger.start() - assertNotNull(csvFile) - assertEquals(csvName, csvFile!!.name) - - csvLogger.logSample(0.1, listOf(24.5, 3.25)) - csvLogger.stop() - - val csvContent = csvFile.readText() - assertTrue(csvContent.contains("01-01 ch")) - assertTrue(csvContent.contains("01-02 ch")) - assertTrue(csvContent.contains("24.5")) - - val mf4Logger = Mf4MeasurementLogger(tempDir, smHeaders, mf4Name) - val mf4File = mf4Logger.start() - assertNotNull(mf4File) - assertEquals(mf4Name, mf4File!!.name) - - mf4Logger.logSample(0.1, listOf(24.5, 3.25)) - mf4Logger.stop() - assertTrue(mf4File.length() > 0) - } -} \ No newline at end of file diff --git a/Android/app/src/test/java/com/dct/app/tests/SensorModulePacketTest.kt b/Android/app/src/test/java/com/dct/app/tests/SensorModulePacketTest.kt deleted file mode 100644 index 7acdea8..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/SensorModulePacketTest.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.domain.model.protocol.DctCommand -import com.dct.app.domain.model.protocol.DctPacket -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -class SensorModulePacketTest { - - private val codec = DctPacketCodec() - - @Test - @DisplayName("SEND_SM_DATA (0xA4) 64채널 패킷 인코딩/디코딩 및 물리값 계산 검증") - fun testSensorModuleDataPacket() { - // 채널 1: 24.50도 (int: 24 -> 0x0018, dec: 50 -> 0x0032) - // 채널 2: -15.75도 (int: -15 -> 0x800F, dec: 75 -> 0x004B) - // 채널 3: NC 미연결 (int: 0xFFFF, dec: 0x0000) - val payload = ByteArray(12) - - // CH 1 - payload[0] = 0x18.toByte() - payload[1] = 0x00.toByte() - payload[2] = 0x32.toByte() - payload[3] = 0x00.toByte() - - // CH 2 - payload[4] = 0x0F.toByte() - payload[5] = 0x80.toByte() - payload[6] = 0x4B.toByte() - payload[7] = 0x00.toByte() - - // CH 3 - payload[8] = 0xFF.toByte() - payload[9] = 0xFF.toByte() - payload[10] = 0x00.toByte() - payload[11] = 0x00.toByte() - - val packet = DctPacket(command = DctCommand.SEND_SM_DATA.code, payload = payload) - val encoded = codec.encode(packet) - val decoded = codec.decode(encoded) - - assertNotNull(decoded) - assertEquals(DctCommand.SEND_SM_DATA.code, decoded!!.command) - - val p = decoded.payload - // CH 1 decode - val int1 = ((p[1].toInt() and 0xFF) shl 8) or (p[0].toInt() and 0xFF) - val dec1 = ((p[3].toInt() and 0xFF) shl 8) or (p[2].toInt() and 0xFF) - assertEquals(24.50, int1 + dec1 / 100.0, 0.001) - - // CH 2 decode (Negative) - val int2 = ((p[5].toInt() and 0xFF) shl 8) or (p[4].toInt() and 0xFF) - val dec2 = ((p[7].toInt() and 0xFF) shl 8) or (p[6].toInt() and 0xFF) - val isMinus = (int2 and 0x8000) != 0 - val realInt2 = (int2 and 0x7FFF) - var val2 = realInt2 + dec2 / 100.0 - if (isMinus) val2 = -val2 - assertEquals(-15.75, val2, 0.001) - - // CH 3 decode (NC) - val int3 = ((p[9].toInt() and 0xFF) shl 8) or (p[8].toInt() and 0xFF) - assertEquals(0xFFFF, int3) - } - - @Test - @DisplayName("CMD_SM_INFO (0x12) 및 RES_SM_INFO (0x92) 커맨드 매핑 검증") - fun testSmInfoCommands() { - assertEquals(0x12.toByte(), DctCommand.CMD_SM_INFO.code) - assertEquals(0x92.toByte(), DctCommand.RES_SM_INFO.code) - assertFalse(DctCommand.isResponse(DctCommand.CMD_SM_INFO.code)) - assertTrue(DctCommand.isResponse(DctCommand.RES_SM_INFO.code)) - } -} \ No newline at end of file diff --git a/Android/app/src/test/java/com/dct/app/tests/SignalColorPresetsTest.kt b/Android/app/src/test/java/com/dct/app/tests/SignalColorPresetsTest.kt deleted file mode 100644 index 6693703..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/SignalColorPresetsTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.theme.PcColorRef -import com.dct.app.core.theme.SignalColorPresets -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class SignalColorPresetsTest { - @Test fun presetsAreDistinctCanonicalPcColorsAndKeepDefaultBlue() { - val colors = SignalColorPresets.colors - assertEquals(24, colors.size) - assertEquals(colors.size, colors.distinct().size) - assertEquals(PcColorRef.DEFAULT_SIGNAL, colors.first()) - colors.forEach { color -> - assertTrue(color in 0L..0xFFFFFFL) - assertEquals(color, PcColorRef.normalize(color)) - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/SignalDragDropManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/SignalDragDropManagerTest.kt deleted file mode 100644 index e6644d4..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/SignalDragDropManagerTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.dct.app.tests - -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import com.dct.app.presentation.chart.DraggedSignal -import com.dct.app.presentation.chart.SignalDragDropManager -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -@DisplayName("SignalDragDropManager - 신호 드래그 앤 드롭 상태 관리자 단위 테스트") -class SignalDragDropManagerTest { - - @BeforeEach - fun setUp() { - SignalDragDropManager.cancelDrag() - } - - @Test - @DisplayName("드래그 시작 시 isDragging이 true가 되고 드래그 신호 정보가 저장되어야 한다") - fun testStartDrag() { - val signal = DraggedSignal( - signalId = "sig_1_1", - signalName = "EngineSpeed", - colorLong = 0xFF0000FF, - unit = "RPM", - isCan = false - ) - SignalDragDropManager.startDrag(signal, Offset(100f, 200f)) - - assertTrue(SignalDragDropManager.isDragging) - assertEquals("EngineSpeed", SignalDragDropManager.draggedSignal?.signalName) - assertEquals(Offset(100f, 200f), SignalDragDropManager.dragPosition) - } - - @Test - @DisplayName("드래그 중 위치 이동(updateDrag)이 정상적으로 누적되어야 한다") - fun testUpdateDrag() { - val signal = DraggedSignal( - signalId = "can_10", - signalName = "VehicleSpeed", - colorLong = 0xFFFF0000, - unit = "km/h", - isCan = true - ) - SignalDragDropManager.startDrag(signal, Offset(100f, 100f)) - SignalDragDropManager.updateDrag(Offset(50f, 30f)) - - assertEquals(Offset(150f, 130f), SignalDragDropManager.dragPosition) - } - - @Test - @DisplayName("드롭 타겟 영역 내에서 endDrag 호출 시 콜백이 실행되어야 한다") - fun testEndDragOnDropTarget() { - val signal = DraggedSignal( - signalId = "a2l_100", - signalName = "CoolantTemp", - colorLong = 0xFF00FF00, - unit = "degC", - isCan = false, - isA2l = true - ) - SignalDragDropManager.registerDropTarget("chart_1", Rect(0f, 0f, 500f, 500f)) - SignalDragDropManager.startDrag(signal, Offset(200f, 200f)) - - var droppedSignal: DraggedSignal? = null - SignalDragDropManager.endDrag { dropped -> - droppedSignal = dropped - } - - assertNotNull(droppedSignal) - assertEquals("CoolantTemp", droppedSignal?.signalName) - assertFalse(SignalDragDropManager.isDragging) - assertNull(SignalDragDropManager.draggedSignal) - - SignalDragDropManager.unregisterDropTarget("chart_1") - } - - @Test - @DisplayName("드래그 취소(cancelDrag) 시 상태가 초기화되어야 한다") - fun testCancelDrag() { - val signal = DraggedSignal( - signalId = "sig_1_1", - signalName = "TestSignal", - colorLong = 0, - unit = "", - isCan = false - ) - SignalDragDropManager.startDrag(signal, Offset(100f, 100f)) - assertTrue(SignalDragDropManager.isDragging) - - SignalDragDropManager.cancelDrag() - assertFalse(SignalDragDropManager.isDragging) - assertNull(SignalDragDropManager.draggedSignal) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/SignalTreeViewModelTest.kt b/Android/app/src/test/java/com/dct/app/tests/SignalTreeViewModelTest.kt deleted file mode 100644 index 855847f..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/SignalTreeViewModelTest.kt +++ /dev/null @@ -1,292 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.DefaultDispatcherProvider -import com.dct.app.domain.model.chart.AxisPosition -import com.dct.app.domain.model.mdb.* -import com.dct.app.presentation.signaltree.SignalTreeFilterMode -import com.dct.app.presentation.signaltree.SignalTreeNodeType -import com.dct.app.presentation.signaltree.SignalTreeViewModel -import com.dct.app.presentation.signaltree.TreeItemSelection -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -@OptIn(ExperimentalCoroutinesApi::class) -@DisplayName("M5 - SignalTreeViewModel 신호 트리 탐색기 단위 테스트") -class SignalTreeViewModelTest { - - private val testDispatcher = UnconfinedTestDispatcher() - private lateinit var dispatchers: TestDispatcherProvider - private lateinit var viewModel: SignalTreeViewModel - private lateinit var sampleDb: MdbDatabase - - @BeforeEach - fun setUp() { - Dispatchers.setMain(testDispatcher) - dispatchers = TestDispatcherProvider(testDispatcher) - viewModel = SignalTreeViewModel(dispatchers) - - // 샘플 테스트 MdbDatabase 구성 - val sig1 = SignalDefinition( - reclid = 1, - idx = 1, - name = "Ta_Engine_Speed", - attr = MdbAttrType.ATTR_2_UINT16, - cvsA = 0.25, - cvsB = 0.0, - unit = "RPM", - color = 0xFF0052CCL, - min = 0.0, - max = 8000.0, - description = "엔진 회전수" - ) - val sig2 = SignalDefinition( - reclid = 1, - idx = 2, - name = "Ta_Coolant_Temp", - attr = MdbAttrType.ATTR_1_INT8, - cvsA = 1.0, - cvsB = -40.0, - unit = "°C", - color = 0xFFBA1A1AL, - min = -40.0, - max = 120.0, - description = "냉각수 온도" - ) - val sig3 = SignalDefinition( - reclid = 2, - idx = 1, - name = "Tb_Battery_Volt", - attr = MdbAttrType.ATTR_2_UINT16, - cvsA = 0.1, - cvsB = 0.0, - unit = "V", - color = 0xFF00875AL, - min = 0.0, - max = 500.0, - description = "배터리 전압" - ) - - val group1 = SignalGroup( - reclid = 1, - strIndex = "G1_ENGINE", - description = "엔진 제어 그룹", - usage = true, - signals = listOf(sig1, sig2) - ) - val group2 = SignalGroup( - reclid = 2, - strIndex = "G2_BATTERY", - description = "배터리 전원 그룹", - usage = true, - signals = listOf(sig3) - ) - - val const1 = MdbConstant( - idx = 1, - name = "K_IDLE_RPM_TARGET", - data = 750.0, - addr = 0x1000L, - min = 500.0, - max = 1200.0, - attr = MdbAttrType.ATTR_2_UINT16, - description = "아이들 목표 RPM" - ) - - val mapPoints = listOf( - MapPoint(idx = 1, bkp = 1000, data = 50, phy = 12.5), - MapPoint(idx = 2, bkp = 2000, data = 100, phy = 25.0) - ) - val map1 = CompensationMap( - type = CompensationMapType.TYPE_A, - idx = 1, - yName = "MAP_TQ_OFFSET", - yAddr = 0x2000L, - yAttr = MdbAttrType.ATTR_2_UINT16, - yMin = 0.0, - yMax = 100.0, - xName = "RPM_AXIS", - xAddr = 0x2010L, - xAttr = MdbAttrType.ATTR_2_UINT16, - cvsA = 0.25, - cvsB = 0.0, - description = "토크 보정 맵", - points = mapPoints - ) - - sampleDb = MdbDatabase( - fileName = "Test_Dct.mdb", - groups = listOf(group1, group2), - constants = listOf(const1), - compensationMaps = listOf(map1) - ) - } - - @AfterEach - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - @DisplayName("setDatabase 호출 시 신호, 상수, 보정 맵 계층 노드가 정확히 생성되어야 한다") - fun testBuildTreeFromDatabase() { - viewModel.setDatabase(sampleDb) - val state = viewModel.uiState.value - - assertEquals(3, state.totalSignalCount) - assertEquals(1, state.totalConstantCount) - assertEquals(1, state.totalMapCount) - - // 3개의 최상위 카테고리 (계측 신호, 제어 상수, 1D/2D 보정 맵) - assertEquals(3, state.rootNodes.size) - val sigCategory = state.rootNodes[0] - assertEquals("root_signals", sigCategory.id) - assertEquals(2, sigCategory.children.size) // 그룹 2개 - - val constCategory = state.rootNodes[1] - assertEquals("root_constants", constCategory.id) - assertEquals(1, constCategory.children.size) - - val mapCategory = state.rootNodes[2] - assertEquals("root_maps", mapCategory.id) - assertEquals(1, mapCategory.children.size) - } - - @Test - @DisplayName("신호명 검색 쿼리 입력 시 일치하는 노드만 필터링되어야 한다 (대소문자 무시)") - fun testSearchQueryFilter() { - viewModel.setDatabase(sampleDb) - - // "engine" 검색 -> Ta_Engine_Speed 및 해당 부모 그룹 포함 - viewModel.onSearchQueryChanged("engine") - - val state = viewModel.uiState.value - assertEquals("engine", state.searchQuery) - - val sigCategory = state.filteredNodes.find { it.id == "root_signals" } - assertNotNull(sigCategory) - - // 엔진 그룹만 매칭되어야 함 (배터리 그룹은 제외) - val engineGroup = sigCategory?.children?.find { it.id == "group_1" } - assertNotNull(engineGroup) - assertTrue(engineGroup?.children?.any { it.name == "Ta_Engine_Speed" } == true) - assertFalse(engineGroup?.children?.any { it.name == "Ta_Coolant_Temp" } == true) - } - - @Test - @DisplayName("단위(Unit)로 검색 쿼리 입력 시 단위가 일치하는 신호가 필터링되어야 한다") - fun testSearchByUnit() { - viewModel.setDatabase(sampleDb) - viewModel.onSearchQueryChanged("°C") - - val state = viewModel.uiState.value - val sigCategory = state.filteredNodes.find { it.id == "root_signals" } - val engineGroup = sigCategory?.children?.find { it.id == "group_1" } - - assertEquals(1, engineGroup?.children?.size) - assertEquals("Ta_Coolant_Temp", engineGroup?.children?.first()?.name) - } - - @Test - @DisplayName("필터 모드를 변경하면 해당 카테고리 노드만 표시되어야 한다") - fun testFilterModes() { - viewModel.setDatabase(sampleDb) - - // 1. CONSTANTS_ONLY - viewModel.onFilterModeChanged(SignalTreeFilterMode.CONSTANTS_ONLY) - val constState = viewModel.uiState.value - assertTrue(constState.filteredNodes.any { it.id == "root_constants" }) - assertFalse(constState.filteredNodes.any { it.id == "root_signals" }) - - // 2. MAPS_ONLY - viewModel.onFilterModeChanged(SignalTreeFilterMode.MAPS_ONLY) - val mapState = viewModel.uiState.value - assertTrue(mapState.filteredNodes.any { it.id == "root_maps" }) - assertFalse(mapState.filteredNodes.any { it.id == "root_signals" }) - - // 3. ALL - viewModel.onFilterModeChanged(SignalTreeFilterMode.ALL) - val allState = viewModel.uiState.value - assertEquals(3, allState.filteredNodes.size) - } - - @Test - @DisplayName("노드 접기/펼치기 토글이 정상적으로 동작해야 한다") - fun testToggleNodeExpansion() { - viewModel.setDatabase(sampleDb) - val groupId = "group_1" - - assertTrue(viewModel.uiState.value.expandedNodeIds.contains(groupId)) - - viewModel.toggleNodeExpansion(groupId) - assertFalse(viewModel.uiState.value.expandedNodeIds.contains(groupId)) - - viewModel.toggleNodeExpansion(groupId) - assertTrue(viewModel.uiState.value.expandedNodeIds.contains(groupId)) - } - - @Test - @DisplayName("전체 펼치기(expandAll)와 전체 접기(collapseAll)가 정상 동작해야 한다") - fun testExpandAndCollapseAll() { - viewModel.setDatabase(sampleDb) - - viewModel.collapseAll() - assertTrue(viewModel.uiState.value.expandedNodeIds.isEmpty()) - - viewModel.expandAll() - assertTrue(viewModel.uiState.value.expandedNodeIds.contains("root_signals")) - assertTrue(viewModel.uiState.value.expandedNodeIds.contains("group_1")) - assertTrue(viewModel.uiState.value.expandedNodeIds.contains("group_2")) - } - - @Test - @DisplayName("노드 선택 시 올바른 TreeItemSelection 페이로드가 발행되어야 한다") - fun testSelectNode() { - viewModel.setDatabase(sampleDb) - - // 1. 신호 노드 선택 - val sigNode = viewModel.uiState.value.rootNodes[0].children[0].children[0] - viewModel.selectNode(sigNode) - - val state = viewModel.uiState.value - assertEquals(sigNode.id, state.selectedNodeId) - assertTrue(state.selectedSelection is TreeItemSelection.SignalSelected) - val sigSelection = state.selectedSelection as TreeItemSelection.SignalSelected - assertEquals("Ta_Engine_Speed", sigSelection.signal.name) - - // 2. 상수 노드 선택 - val constNode = viewModel.uiState.value.rootNodes[1].children[0] - viewModel.selectNode(constNode) - assertTrue(viewModel.uiState.value.selectedSelection is TreeItemSelection.ConstantSelected) - } - - @Test - @DisplayName("차트 축 바인딩 요청(requestAddToChart) 시 Flow 이벤트가 정상 방출되어야 한다") - fun testRequestAddToChart() = runTest { - viewModel.setDatabase(sampleDb) - val sigNode = viewModel.uiState.value.rootNodes[0].children[0].children[0] - - var emittedReq = false - val job = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.addToChartEvents.collect { req -> - assertEquals("Ta_Engine_Speed", req.signalName) - assertEquals(AxisPosition.RIGHT_1, req.axisPosition) - assertEquals("RPM", req.unit) - emittedReq = true - } - } - - viewModel.requestAddToChart(sigNode, AxisPosition.RIGHT_1) - assertTrue(emittedReq) - } -} 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 deleted file mode 100644 index c79ffdd..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt +++ /dev/null @@ -1,444 +0,0 @@ -package com.dct.app.tests - -import com.google.common.truth.Truth.assertThat -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.data.protocol.SlidingRingBufferParser -import com.dct.app.domain.model.protocol.DctCommand -import com.dct.app.domain.model.protocol.DctPacket -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import java.io.ByteArrayOutputStream - -@DisplayName("SlidingRingBufferParser 스트림 파싱 및 복구 단위 테스트") -class SlidingRingBufferParserTest { - - private lateinit var parser: SlidingRingBufferParser - private lateinit var codec: DctPacketCodec - - @BeforeEach - fun setUp() { - parser = SlidingRingBufferParser() - codec = DctPacketCodec() - } - - @Test fun diagnosticsAccountForFragmentsNoiseAndChecksumWithoutChangingParsing() { - val frame = codec.encode(DctPacket.create(DctCommand.SEND_CAN_FRAME, byteArrayOf(1, 2, 3))) - val corrupt = frame.copyOf().apply { this[lastIndex - 1] = (this[lastIndex - 1].toInt() xor 1).toByte() } - val prefix = byteArrayOf(9, 8) + corrupt + frame.copyOfRange(0, 4) - assertThat(parser.parseStream(prefix)).isEmpty() - val pending = parser.diagnostics() - assertThat(pending.pendingBytes).isEqualTo(4) - assertThat(pending.expectedBytes).isEqualTo(frame.size) - assertThat(pending.discarded[SlidingRingBufferParser.DiscardReason.CHECKSUM]).isEqualTo(1L) - assertThat(pending.inputBytes).isEqualTo(pending.frameBytes + pending.discardedBytes + pending.pendingBytes) - assertThat(parser.parseStream(frame.copyOfRange(4, frame.size))).hasSize(1) - val complete = parser.diagnostics() - assertThat(complete.frameBytes).isEqualTo(frame.size.toLong()) - assertThat(complete.frames).isEqualTo(1L) - assertThat(complete.pendingBytes).isEqualTo(0) - assertThat(complete.inputBytes).isEqualTo(complete.frameBytes + complete.discardedBytes) - parser.reset() - assertThat(parser.diagnostics().inputBytes).isEqualTo(0L) - assertThat(parser.diagnostics().discardedBytes).isEqualTo(0L) - } - - @Test fun diagnosticSnapshotDoesNotConsumePartialHeader() { - val frame = codec.encode(DctPacket.create(DctCommand.CMD_ENABLE)) - parser.parseStream(frame.copyOfRange(0, 2)) - repeat(10) { - assertThat(parser.diagnostics().pendingBytes).isEqualTo(2) - assertThat(parser.diagnostics().expectedBytes).isNull() - } - assertThat(parser.parseStream(frame.copyOfRange(2, frame.size))).hasSize(1) - assertThat(parser.diagnostics().discardedBytes).isEqualTo(0L) - } - - @Test - @DisplayName("단일 완전한 패킷 입력 시 즉시 파싱 성공") - fun testParseSingleCompletePacket() { - val packet = DctPacket.create(DctCommand.CMD_ENABLE, byteArrayOf(0x01, 0x02)) - val encoded = codec.encode(packet) - - val result = parser.parseStream(encoded) - assertThat(result).hasSize(1) - assertThat(result[0].command).isEqualTo(DctCommand.CMD_ENABLE.code) - assertThat(result[0].payload).isEqualTo(byteArrayOf(0x01, 0x02)) - } - - @Test - @DisplayName("복수 패킷이 결합된 연속 스트림(Coalesced Packets) 일괄 파싱 검증") - fun testParseMultipleCoalescedPackets() { - val stream = ByteArrayOutputStream() - val packetsToSend = (1..20).map { seq -> - val p = DctPacket.create( - command = DctCommand.SEND_CAN_FRAME, - payload = byteArrayOf(seq.toByte(), 0x10, 0x20), - sequence = seq - ) - stream.write(codec.encode(p)) - p - } - - val allBytes = stream.toByteArray() - val parsedPackets = parser.parseStream(allBytes) - - assertThat(parsedPackets).hasSize(20) - for (i in 0 until 20) { - assertThat(parsedPackets[i].command).isEqualTo(DctCommand.SEND_CAN_FRAME.code) - assertThat(parsedPackets[i].payload).isEqualTo(packetsToSend[i].payload) - } - } - - @Test - @DisplayName("1바이트씩 극단적으로 쪼개져 들어오는 조각화 스트림(Fragmented Chunks) 완벽 조립") - fun testParseFragmentedOneByteAtATime() { - val packet = DctPacket.create( - command = DctCommand.CMD_VERSION, - payload = "TEST_VERSION_STRING_12345".toByteArray() - ) - val frame = codec.encode(packet) - - val accumulatedResult = mutableListOf() - for (b in frame) { - val res = parser.parseStream(byteArrayOf(b)) - accumulatedResult.addAll(res) - } - - assertThat(accumulatedResult).hasSize(1) - assertThat(accumulatedResult[0].command).isEqualTo(DctCommand.CMD_VERSION.code) - assertThat(accumulatedResult[0].payload).isEqualTo(packet.payload) - } - - @Test - @DisplayName("헤더 이전 및 패킷 사이 가비지(Noise) 바이트 자동 스킵 및 복구") - fun testGarbageBytesSkipping() { - val validPacket = DctPacket.create(DctCommand.CMD_HWID, byteArrayOf(0xAA.toByte(), 0xBB.toByte())) - val validFrame = codec.encode(validPacket) - - val stream = ByteArrayOutputStream() - // 1. 앞쪽 가비지 - stream.write(byteArrayOf(0x00, 0x12, 0x34, 0x7E, 0x55, 0x99.toByte())) - // 2. 정상 패킷 - stream.write(validFrame) - // 3. 중간 가비지 - stream.write(byteArrayOf(0xFE.toByte(), 0xDC.toByte(), 0x00)) - // 4. 또 다른 정상 패킷 - val validPacket2 = DctPacket.create(DctCommand.CMD_SYNC_TIME, byteArrayOf(0x01)) - stream.write(codec.encode(validPacket2)) - - val result = parser.parseStream(stream.toByteArray()) - assertThat(result).hasSize(2) - assertThat(result[0].command).isEqualTo(DctCommand.CMD_HWID.code) - assertThat(result[0].payload).isEqualTo(validPacket.payload) - assertThat(result[1].command).isEqualTo(DctCommand.CMD_SYNC_TIME.code) - assertThat(result[1].payload).isEqualTo(validPacket2.payload) - } - - @Test - @DisplayName("페이로드 내에 0x7E 및 0xA1 바이트가 포함되어 있어도 정상 파싱") - fun testPayloadContainingHeaderOrFooterBytes() { - // 페이로드에 0x7E와 0xA1이 마구 섞여 있는 경우 - val trickyPayload = byteArrayOf(0x7E, 0x7E, 0xA1.toByte(), 0x00, 0x7E, 0xA1.toByte()) - val packet = DctPacket.create(DctCommand.CMD_MDB_CONSTANT_DATA, trickyPayload) - val encoded = codec.encode(packet) - - val result = parser.parseStream(encoded) - assertThat(result).hasSize(1) - assertThat(result[0].command).isEqualTo(DctCommand.CMD_MDB_CONSTANT_DATA.code) - assertThat(result[0].payload).isEqualTo(trickyPayload) - } - - @Test - @DisplayName("체크섬 오류가 있는 손상된 패킷 폐기 후 바로 다음 정상 패킷 파싱") - fun testChecksumCorruptedPacketRecovery() { - val goodPacket1 = DctPacket.create(DctCommand.CMD_ENABLE) - val goodFrame1 = codec.encode(goodPacket1) - - val badPacket = DctPacket.create(DctCommand.CMD_VERSION, "BAD".toByteArray()) - val badFrame = codec.encode(badPacket) - badFrame[badFrame.size - 2] = (badFrame[badFrame.size - 2] + 1).toByte() // 체크섬 고의 변조 - - val goodPacket2 = DctPacket.create(DctCommand.CMD_CARTYPE, "MQ4".toByteArray()) - val goodFrame2 = codec.encode(goodPacket2) - - val stream = ByteArrayOutputStream() - stream.write(goodFrame1) - stream.write(badFrame) - stream.write(goodFrame2) - - val result = parser.parseStream(stream.toByteArray()) - // badFrame은 체크섬 실패로 드롭되어야 함 - assertThat(result).hasSize(2) - assertThat(result[0].command).isEqualTo(DctCommand.CMD_ENABLE.code) - assertThat(result[1].command).isEqualTo(DctCommand.CMD_CARTYPE.code) - assertThat(result[1].payload).isEqualTo("MQ4".toByteArray()) - } - - @Test - @DisplayName("reset 메서드 동작 및 내부 상태 초기화 검증") - fun testResetAndStateCleanup() { - val packet = DctPacket.create(DctCommand.CMD_CUR_STATUS) - val result = parser.parseStream(codec.encode(packet)) - - assertThat(result).hasSize(1) - assertThat(result[0].command).isEqualTo(DctCommand.CMD_CUR_STATUS.code) - - // Reset 검증 - parser.parseStream(codec.encode(packet)) - parser.reset() - assertThat(parser.availableByteCount).isEqualTo(0) - } - - @Test - @DisplayName("가짜 0xA1 헤더(푸터 불일치) 유입 시 1바이트 백트래킹으로 후속 정상 패킷 100% 복구") - fun testFalseA1HeaderRollbackAndSubsequentPacketRecovery() { - // 가짜 0xA1 프레임: 0xA1 헤더에 packLen=6이지만 끝이 0x7E가 아님 - val fakeFrame = byteArrayOf(0xA1.toByte(), 0x00, 0x06, 0x10, 0x20, 0x30, 0x40, 0x50, 0x00, 0x11, 0x22) - val validPacket = DctPacket.create(DctCommand.CMD_VERSION, "GENUINE_RECOVERED".toByteArray()) - val validFrame = codec.encode(validPacket) - - val stream = ByteArrayOutputStream() - stream.write(fakeFrame) - stream.write(validFrame) - - val result = parser.parseStream(stream.toByteArray()) - assertThat(result).hasSize(1) - assertThat(result[0].command).isEqualTo(DctCommand.CMD_VERSION.code) - assertThat(result[0].payload).isEqualTo("GENUINE_RECOVERED".toByteArray()) - } - - @Test - @DisplayName("연속된 다중 가짜 0xA1 노이즈 유입 시 완벽한 백트래킹 순환 및 정상 패킷 복구") - fun testConsecutiveFalseA1HeadersBacktracking() { - // A1 A1 length is only invalid with this bounded-frame policy. With the - // full wire range it can be an incomplete 41 KB frame and must be retained. - parser = SlidingRingBufferParser(maxPacketSize = 8192) - val stream = ByteArrayOutputStream() - // 가짜 0xA1 바이트 5개 연속 - stream.write(byteArrayOf(0xA1.toByte(), 0xA1.toByte(), 0xA1.toByte(), 0xA1.toByte(), 0xA1.toByte())) - - val validPacket = DctPacket.create(DctCommand.CMD_CARTYPE, "TEST_CAR".toByteArray()) - stream.write(codec.encode(validPacket)) - - val result = parser.parseStream(stream.toByteArray()) - assertThat(result).hasSize(1) - assertThat(result[0].command).isEqualTo(DctCommand.CMD_CARTYPE.code) - assertThat(result[0].payload).isEqualTo("TEST_CAR".toByteArray()) - } - - @Test - @DisplayName("비정상적인 packLen(0 또는 초과 크기) 가짜 헤더 유입 시 즉시 1바이트 백트래킹") - fun testFalseHeaderLengthOutOfBoundsBacktracking() { - parser = SlidingRingBufferParser(maxPacketSize = 8192) - // packLen = 0x0000 (최소 1 이상이어야 함) - val invalidLenFrame1 = byteArrayOf(0xA1.toByte(), 0x00, 0x00, 0x00, 0x7E) - // packLen = 0xFFFF (maxPacketSize 초과) - val invalidLenFrame2 = byteArrayOf(0xA1.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0x00, 0x7E) - - val validPacket = DctPacket.create(DctCommand.CMD_ENABLE) - val validFrame = codec.encode(validPacket) - - val stream = ByteArrayOutputStream() - stream.write(invalidLenFrame1, 0, invalidLenFrame1.size) - stream.write(invalidLenFrame2, 0, invalidLenFrame2.size) - stream.write(validFrame, 0, validFrame.size) - - val result = parser.parseStream(stream.toByteArray()) - 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/SlimScrollbarMetricsTest.kt b/Android/app/src/test/java/com/dct/app/tests/SlimScrollbarMetricsTest.kt deleted file mode 100644 index e97bd6c..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/SlimScrollbarMetricsTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.presentation.signaltree.slimScrollbarMetrics -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class SlimScrollbarMetricsTest { - @Test fun thumbTracksBeginningMiddleAndEnd() { - val start = slimScrollbarMetrics(100, 20f, 200f, 200f, 0f, 20f)!! - val middle = slimScrollbarMetrics(100, 20f, 200f, 200f, 900f, 20f)!! - val end = slimScrollbarMetrics(100, 20f, 200f, 200f, 1800f, 20f)!! - - assertEquals(20f, start.thumbHeightPx, 0.001f) - assertEquals(0f, start.thumbTopPx, 0.001f) - assertEquals(90f, middle.thumbTopPx, 0.001f) - assertEquals(180f, end.thumbTopPx, 0.001f) - assertEquals(1800f, end.maxScrollPx, 0.001f) - } - - @Test fun noScrollbarWhenContentFitsAndThumbNeverExceedsTrack() { - assertNull(slimScrollbarMetrics(5, 20f, 200f, 200f, 0f, 20f)) - val shortTrack = slimScrollbarMetrics(100, 20f, 200f, 12f, 1800f, 20f)!! - assertEquals(12f, shortTrack.thumbHeightPx, 0.001f) - assertEquals(0f, shortTrack.travelPx, 0.001f) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/TestDispatcherProvider.kt b/Android/app/src/test/java/com/dct/app/tests/TestDispatcherProvider.kt deleted file mode 100644 index a0cbfa5..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/TestDispatcherProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.common.DispatcherProvider -import kotlinx.coroutines.CoroutineDispatcher - -class TestDispatcherProvider( - private val testDispatcher: CoroutineDispatcher -) : DispatcherProvider { - override val main: CoroutineDispatcher = testDispatcher - override val mainImmediate: CoroutineDispatcher = testDispatcher - override val io: CoroutineDispatcher = testDispatcher - override val default: CoroutineDispatcher = testDispatcher - override val unconfined: CoroutineDispatcher = testDispatcher -} diff --git a/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt b/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt deleted file mode 100644 index 4fa905a..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import kotlin.system.measureTimeMillis - -@DisplayName("TimeSeriesRingBuffer 고성능 제로-GC 원형 버퍼 단위 테스트") -class TimeSeriesRingBufferTest { - - @Test - @DisplayName("용량 미만 추가 시 정확한 크기와 시간/값 순서가 보존되어야 한다") - fun testUnderCapacityAddition() { - val buffer = TimeSeriesRingBuffer(10) - assertEquals(0, buffer.size) - - buffer.addSample(1.0, 100.0) - buffer.addSample(2.0, 200.0) - buffer.addSample(3.0, 300.0) - - assertEquals(3, buffer.size) - assertEquals(1.0, buffer.getTimestampAt(0), 1e-6) - assertEquals(100.0, buffer.getValueAt(0), 1e-6) - - assertEquals(2.0, buffer.getTimestampAt(1), 1e-6) - assertEquals(200.0, buffer.getValueAt(1), 1e-6) - - assertEquals(3.0, buffer.getTimestampAt(2), 1e-6) - assertEquals(300.0, buffer.getValueAt(2), 1e-6) - - assertEquals(1.0, buffer.getOldestTimestamp()!!, 1e-6) - assertEquals(3.0, buffer.getLatestTimestamp()!!, 1e-6) - assertEquals(300.0, buffer.getLatestValue()!!, 1e-6) - } - - @Test - @DisplayName("용량 초과 시 가장 오래된 데이터가 FIFO로 덮어쓰여지고 head가 전진해야 한다") - fun testOverCapacityFifoOverwrite() { - val buffer = TimeSeriesRingBuffer(5) - - // 1..5 추가 (가득 참) - for (i in 1..5) { - buffer.addSample(i.toDouble(), (i * 10).toDouble()) - } - assertEquals(5, buffer.size) - assertEquals(1.0, buffer.getTimestampAt(0), 1e-6) - assertEquals(10.0, buffer.getValueAt(0), 1e-6) - - // 6 추가 (1이 덮어쓰여지고 2가 0번 인덱스가 됨) - buffer.addSample(6.0, 60.0) - assertEquals(5, buffer.size) - assertEquals(2.0, buffer.getTimestampAt(0), 1e-6) - assertEquals(20.0, buffer.getValueAt(0), 1e-6) - assertEquals(6.0, buffer.getTimestampAt(4), 1e-6) - assertEquals(60.0, buffer.getValueAt(4), 1e-6) - - // 7, 8 추가 - buffer.addSample(7.0, 70.0) - buffer.addSample(8.0, 80.0) - assertEquals(5, buffer.size) - assertEquals(4.0, buffer.getTimestampAt(0), 1e-6) - assertEquals(40.0, buffer.getValueAt(0), 1e-6) - assertEquals(8.0, buffer.getTimestampAt(4), 1e-6) - assertEquals(80.0, buffer.getValueAt(4), 1e-6) - } - - @Test - @DisplayName("10만 개 대량 추가 시 메모리 누수 없이 O(1) 원소 접근이 가능해야 한다") - fun testLargeScaleInsertionAndAccessPerformance() { - val capacity = 100_000 - val buffer = TimeSeriesRingBuffer(capacity) - - val elapsed = measureTimeMillis { - for (i in 0 until 150_000) { - buffer.addSample(i * 0.01, (i % 1000).toDouble()) - } - } - - assertEquals(capacity, buffer.size) - // 15만 개 추가 시 가장 오래된 것은 50,000번째 (timestamp: 500.0) - assertEquals(500.0, buffer.getTimestampAt(0), 1e-6) - assertEquals((50000 % 1000).toDouble(), buffer.getValueAt(0), 1e-6) - - // 가장 최신은 149,999번째 (timestamp: 1499.99) - assertEquals(1499.99, buffer.getTimestampAt(capacity - 1), 1e-6) - - // 15만 개 삽입 시간이 500ms 미만이어야 함 - assertTrue(elapsed < 500, "15만개 추가 시간이 500ms 이내여야 합니다 (소요: ${elapsed}ms)") - } - - @Test - @DisplayName("clear() 호출 시 버퍼 상태가 초기화되어야 한다") - fun testClearBuffer() { - val buffer = TimeSeriesRingBuffer(10) - for (i in 1..10) { - buffer.addSample(i.toDouble(), i.toDouble()) - } - assertEquals(10, buffer.size) - - buffer.clear() - assertEquals(0, buffer.size) - assertNull(buffer.getLatestTimestamp()) - assertNull(buffer.getOldestValue()) - assertThrows(IndexOutOfBoundsException::class.java) { - buffer.getTimestampAt(0) - } - - // 초기화 후 재추가 시 정상 동작 - buffer.addSample(100.0, 55.5) - assertEquals(1, buffer.size) - assertEquals(100.0, buffer.getTimestampAt(0), 1e-6) - assertEquals(55.5, buffer.getValueAt(0), 1e-6) - } - - @Test - @DisplayName("선형 보간(interpolateAt)이 정확한 중간값을 계산해야 한다") - fun testLinearInterpolation() { - val buffer = TimeSeriesRingBuffer(10) - buffer.addSample(10.0, 100.0) - buffer.addSample(20.0, 200.0) - buffer.addSample(30.0, 400.0) - - // 1. 정확한 지점 - assertEquals(100.0, buffer.interpolateAt(10.0)!!, 1e-6) - assertEquals(200.0, buffer.interpolateAt(20.0)!!, 1e-6) - - // 2. 중간 지점 (15.0 -> 150.0) - assertEquals(150.0, buffer.interpolateAt(15.0)!!, 1e-6) - - // 3. 중간 지점 (25.0 -> 300.0) - assertEquals(300.0, buffer.interpolateAt(25.0)!!, 1e-6) - - // 4. 범위 밖 클램핑 - assertEquals(100.0, buffer.interpolateAt(5.0)!!, 1e-6) - assertEquals(400.0, buffer.interpolateAt(40.0)!!, 1e-6) - } - - @Test - @DisplayName("통신 단절 마커는 NaN으로 저장되어 다음 샘플과 선을 연결하지 않아야 한다") - fun testDataGapMarker() { - val buffer = TimeSeriesRingBuffer(10) - buffer.addSample(1.0, 10.0) - buffer.addGap(2.0) - buffer.addSample(3.0, 30.0) - - assertEquals(3, buffer.size) - assertTrue(buffer.getValueAt(1).isNaN()) - assertEquals(listOf(1.0, 2.0, 3.0), buffer.toList().map { it.first }) - } - - @Test - @DisplayName("동일 TCP read에서 들어온 ingress 프레임은 1ms 미만이어도 덮어쓰지 않아야 한다") - fun forceAppendPreservesSubMillisecondIngressFrames() { - val buffer = TimeSeriesRingBuffer(8) - buffer.addSample(10.000000, 1.0, forceAppend = true) - buffer.addSample(10.000100, 2.0, forceAppend = true) - buffer.addSample(10.000100, 3.0, forceAppend = true) - - assertEquals(3, buffer.size) - assertEquals(1.0, buffer.getValueAt(0), 1e-9) - assertEquals(2.0, buffer.getValueAt(1), 1e-9) - assertEquals(3.0, buffer.getValueAt(2), 1e-9) - assertTrue(buffer.getTimestampAt(1) > buffer.getTimestampAt(0)) - assertTrue(buffer.getTimestampAt(2) > buffer.getTimestampAt(1)) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt b/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt deleted file mode 100644 index 4323c23..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.dct.app.tests - -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.gestures.ScrollScope -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.Velocity -import com.dct.app.presentation.monitor.components.TwoFingerFling -import com.dct.app.presentation.monitor.components.flingWorkspace -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class TwoFingerFlingTest { - private fun moving(limit: Float = 10000f) = TwoFingerFling(limit).apply { - for (i in 0..8) add(i * 10L, Offset(300f - i * 10f, 300f - i * 5f)) - } - @Test fun releaseKeepsCentroidSpeedWithScrollDirectionAndNoGain() { - val fling = moving() - fling.release(90, true) - val velocity = fling.finish(100) - assertEquals(1000f, velocity.x, 1f) - assertEquals(500f, velocity.y, 1f) - } - @Test fun platformMaximumVelocityIsRespectedPerAxis() { - val fling = moving(300f) - fling.release(80, true) - assertEquals(Velocity(300f, 300f), fling.finish(80)) - } - @Test fun stationaryGestureDoesNotFling() { - val fling = TwoFingerFling(10000f) - for (i in 0..8) fling.add(i * 10L, Offset(20f, 20f)) - fling.release(90, true) - assertEquals(0f, fling.finish(90).x, .01f) - assertEquals(0f, fling.finish(90).y, .01f) - } - @Test fun pausedMovementDoesNotReuseStaleVelocity() { - val fling = moving() - fling.release(200, true) - assertEquals(Velocity.Zero, fling.finish(210)) - } - @Test fun pinchOrUnconfirmedGestureDoesNotFling() { - val fling = moving() - fling.release(90, false) - assertEquals(Velocity.Zero, fling.finish(100)) - } - @Test fun holdingRemainingFingerSuppressesDelayedFling() { - val fling = moving() - fling.release(90, true) - // Discard velocity when remaining finger stays down past the 250ms hand-off grace window. - assertEquals(Velocity.Zero, fling.finish(360)) - } - @Test fun sequentialFingerReleaseWithinGraceKeepsTwoFingerFling() { - val fling = moving() - fling.release(90, true) - val velocity = fling.finish(200) - assertEquals(1000f, velocity.x, 1f) - assertEquals(500f, velocity.y, 1f) - } - @Test fun remainingFingerCannotOverwriteCentroidVelocity() { - val fling = moving() - fling.release(90, true) - fling.add(100, Offset(-2000f, 2000f)) - assertEquals(1000f, fling.finish(100).x, 1f) - assertEquals(500f, fling.finish(100).y, 1f) - } - @Test fun cancellationOrThirdFingerCannotRestartFling() { - val fling = moving() - fling.release(90, true) - fling.cancel() - fling.release(100, true) - assertEquals(Velocity.Zero, fling.finish(100)) - } - @Test fun incompleteGestureCannotFling() { - assertEquals(Velocity.Zero, moving().finish(90)) - } - @Test fun suppliedAxisBehaviorsReceiveUnscaledVelocityAndScrollIndependently() = runTest { - val x = ScrollState(0) - val y = ScrollState(0) - val velocities = mutableListOf() - val behavior = object : FlingBehavior { - override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { - velocities += initialVelocity - scrollBy(initialVelocity / 10f) - return 0f - } - } - flingWorkspace(x, y, behavior, behavior, Velocity(1000f, 500f)) - assertEquals(listOf(1000f, 500f), velocities) - assertEquals(100, x.value) - assertEquals(50, y.value) - } - @Test fun cancellationStopsBothAxisAnimations() = runTest { - val x = ScrollState(0) - val y = ScrollState(0) - var stopped = 0 - val behavior = object : FlingBehavior { - override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { - try { awaitCancellation() } finally { stopped++ } - } - } - val job = launch { flingWorkspace(x, y, behavior, behavior, Velocity(1000f, 500f)) } - runCurrent() - assertTrue(x.isScrollInProgress) - assertTrue(y.isScrollInProgress) - job.cancel() - runCurrent() - assertEquals(2, stopped) - assertFalse(x.isScrollInProgress) - assertFalse(y.isScrollInProgress) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt b/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt deleted file mode 100644 index 1ce3aeb..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.presentation.monitor.components.TwoFingerGesture -import com.dct.app.presentation.monitor.components.WorkspaceTouchNavigation -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class TwoFingerGestureTest { - private fun gesture(zoom: Boolean = true) = TwoFingerGesture(10f, zoom).apply { update(100f, 100f, 100f) } - - @Test fun firstContactAndSmallJitterDoNotMoveAnything() { - val g = gesture() - assertEquals(TwoFingerGesture.Motion(), g.update(105f, 104f, 108f)) - assertEquals(TwoFingerGesture.Mode.Pending, g.mode) - } - @Test fun translationCrossesSlopThenFollowsBothAxesOneToOne() { - val g = gesture() - val first = g.update(130f, 140f, 100f) - assertEquals(24f, first.dx) - assertEquals(32f, first.dy) - assertEquals(TwoFingerGesture.Motion(3f, -4f), g.update(133f, 136f, 100f)) - assertEquals(TwoFingerGesture.Mode.Pan, g.mode) - } - @Test fun zoomUsesSeparationAndNeverEmitsPan() { - val g = gesture() - assertEquals(TwoFingerGesture.Motion(zoom = 1.4f), g.update(100f, 100f, 140f)) - assertEquals(TwoFingerGesture.Motion(zoom = 0.5f), g.update(100f, 100f, 70f)) - assertEquals(TwoFingerGesture.Mode.Zoom, g.mode) - } - @Test fun ambiguousCombinedMovementWaitsForDominance() { - val g = gesture() - assertEquals(TwoFingerGesture.Motion(), g.update(120f, 100f, 140f)) - assertEquals(TwoFingerGesture.Mode.Pending, g.mode) - g.update(150f, 100f, 142f) - assertEquals(TwoFingerGesture.Mode.Pan, g.mode) - } - @Test fun translationNeverTurnsIntoZoomMidGesture() { - val g = gesture() - g.update(120f, 100f, 100f) - assertEquals(TwoFingerGesture.Motion(1f, 2f), g.update(121f, 102f, 200f)) - assertEquals(TwoFingerGesture.Mode.Pan, g.mode) - } - @Test fun zoomNeverTurnsIntoCanvasPanMidGesture() { - val g = gesture() - g.update(100f, 100f, 140f) - assertEquals(TwoFingerGesture.Motion(), g.update(300f, 400f, 140f)) - assertEquals(TwoFingerGesture.Mode.Zoom, g.mode) - } - @Test fun outsideGraphOnlyTranslationIsAvailable() { - val g = gesture(false) - assertEquals(TwoFingerGesture.Motion(), g.update(100f, 100f, 160f)) - assertEquals(TwoFingerGesture.Mode.Pending, g.mode) - assertEquals(TwoFingerGesture.Motion(-10f, 0f), g.update(80f, 100f, 160f)) - } - @Test fun fingerLiftOrReplacementCannotResumeUntilNewGesture() { - val g = gesture() - g.update(130f, 100f, 100f) - g.end() - assertEquals(TwoFingerGesture.Motion(), g.update(300f, 100f, 200f)) - assertEquals(TwoFingerGesture.Mode.Ended, g.mode) - } - @Test fun invalidGeometryCannotPoisonFollowingEvents() { - val g = gesture() - assertEquals(TwoFingerGesture.Motion(), g.update(Float.NaN, 0f, 100f)) - assertEquals(TwoFingerGesture.Motion(10f, 0f), g.update(120f, 100f, 100f)) - } - @Test fun pageNavigationOwnershipAndCallbacksAreIndependentAndResettable() { - val a = WorkspaceTouchNavigation() - val b = WorkspaceTouchNavigation() - a.zoomAtRoot = { _, _ -> } - a.capture() - assertTrue(a.ownsTouches) - assertFalse(b.ownsTouches) - assertNull(b.zoomAtRoot) - a.reset() - assertFalse(a.ownsTouches) - assertNull(a.zoomAtRoot) - } - - @Test fun windowTouchReservationSurvivesGestureResetUntilWindowDisposes() { - val navigation = WorkspaceTouchNavigation() - navigation.registerWindowTouchRegion("window") { Rect(10f, 20f, 110f, 120f) } - - assertTrue(navigation.isWindowTouchReserved(Offset(50f, 60f))) - navigation.reset() - assertTrue( - navigation.isWindowTouchReserved(Offset(50f, 60f)), - "Window reservations are persistent, unlike per-gesture claims" - ) - - navigation.unregisterWindowTouchRegion("window") - assertFalse(navigation.isWindowTouchReserved(Offset(50f, 60f))) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt deleted file mode 100644 index 27e3198..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt +++ /dev/null @@ -1,341 +0,0 @@ -package com.dct.app.tests - -import android.content.Context -import android.content.pm.PackageManager -import android.location.LocationManager -import android.net.* -import android.net.wifi.* -import android.os.Handler -import androidx.core.content.ContextCompat -import androidx.core.location.LocationManagerCompat -import com.dct.app.data.communication.* -import io.mockk.* -import org.junit.jupiter.api.* -import org.junit.jupiter.api.Assertions.* -import java.net.InetAddress - -class WifiApManagerTest { - private val context = mockk(relaxed = true) - private val wifi = mockk(relaxed = true) - private val connectivity = mockk(relaxed = true) - private val location = mockk() - private val callbacks = mutableListOf() - private val timeouts = mutableListOf() - private var ready = 0; private var lost = 0 - private lateinit var manager: WifiApManager - private val device = WifiApDevice("DCT", "", -40, WifiApSecurity.WPA2) - - @BeforeEach fun setup() { - mockkStatic(ContextCompat::class, LocationManagerCompat::class) - every { context.applicationContext } returns context - every { context.getSystemService(WifiManager::class.java) } returns wifi - every { context.getSystemService(ConnectivityManager::class.java) } returns connectivity - every { context.getSystemService(LocationManager::class.java) } returns location - every { wifi.isWifiEnabled } returns true - every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_GRANTED - every { ContextCompat.registerReceiver(context, any(), any(), ContextCompat.RECEIVER_EXPORTED) } returns null - every { LocationManagerCompat.isLocationEnabled(location) } returns true - mockkConstructor(Handler::class, WifiNetworkSpecifier.Builder::class, NetworkRequest.Builder::class) - every { anyConstructed().postDelayed(any(), any()) } answers { timeouts += firstArg(); true } - every { anyConstructed().removeCallbacks(any()) } just Runs - every { anyConstructed().setSsid(any()) } answers { self as WifiNetworkSpecifier.Builder } - every { anyConstructed().setWpa2Passphrase(any()) } answers { self as WifiNetworkSpecifier.Builder } - every { anyConstructed().build() } returns mockk() - every { anyConstructed().addTransportType(any()) } answers { self as NetworkRequest.Builder } - every { anyConstructed().clearCapabilities() } answers { self as NetworkRequest.Builder } - every { anyConstructed().removeCapability(any()) } answers { self as NetworkRequest.Builder } - every { anyConstructed().setNetworkSpecifier(any()) } answers { self as NetworkRequest.Builder } - every { anyConstructed().build() } returns mockk() - every { connectivity.requestNetwork(any(), any()) } answers { callbacks += secondArg() } - every { connectivity.registerNetworkCallback(any(), any()) } answers { callbacks += secondArg() } - manager = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34) - } - @AfterEach fun teardown() { manager.close(); unmockkAll() } - - @Test fun recoveryRestoresOnlyTheSelectedApWithoutEndingMeasurementOrDuplicatingRequests() { - var switching = 0 - val owner = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34, onConnecting = { switching++ }) - try { - owner.connect(device, "12345678") - val first = callbacks.last(); val network = mockk() - first.onLinkPropertiesChanged(network, properties("192.168.4.20")) - first.onLost(network) - assertEquals(1, lost) - assertFalse(owner.recoverLastConnection()) // IP callback has not arrived yet. - assertEquals(2, callbacks.size) - assertFalse(owner.recoverLastConnection()) // CONNECTING is already in progress. - assertEquals(2, callbacks.size) - assertEquals(1, switching, "An automatic retry must not invoke manual session teardown") - first.onLinkPropertiesChanged(network, properties("192.168.4.20")) - assertFalse(owner.uiState.value.networkReady) - callbacks.last().onLinkPropertiesChanged(mockk(), properties("192.168.4.21")) - assertEquals(2, ready) - assertTrue(owner.recoverLastConnection()) - owner.disconnect() - assertFalse(owner.recoverLastConnection()) - assertEquals(2, callbacks.size, "Explicit Disconnect clears AP retry intent") - } finally { owner.close() } - } - - @Test fun settingsNetworkRecoveryNeverAdoptsAnUnrelatedWifi() { - fun network(name: String): Network { - val network = mockk() - val capabilities = mockk() - val info = mockk() - every { info.ssid } returns "\"$name\"" - every { capabilities.transportInfo } returns info - every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true - every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false - every { connectivity.getNetworkCapabilities(network) } returns capabilities - every { connectivity.getLinkProperties(network) } returns properties("192.168.4.20") - return network - } - val original = network("DCT") - every { connectivity.allNetworks } returns arrayOf(original) - manager.connectCurrent() - callbacks.last().onLost(original) - val unrelated = network("Office") - every { connectivity.allNetworks } returns arrayOf(unrelated) - assertFalse(manager.recoverLastConnection()) - assertEquals(1, ready) - every { connectivity.getNetworkCapabilities(original) } returns connectivity.getNetworkCapabilities(unrelated) - every { connectivity.allNetworks } returns arrayOf(original) - assertFalse(manager.recoverLastConnection(), "A known SSID must match even when Network identity is reused") - every { connectivity.allNetworks } returns arrayOf(network("DCT")) - assertTrue(manager.recoverLastConnection()) - assertEquals(2, ready) - } - private fun properties(address: String): LinkProperties { - val link = mockk(); every { link.address } returns InetAddress.getByName(address) - val properties = mockk(); every { properties.linkAddresses } returns listOf(link) - return properties - } - @Test fun tcpStartsOnlyAfterEquipmentIpAndCancelIgnoresLateCallbacks() { - manager.connect(device, "12345678") - val callback = callbacks.single(); val network = mockk() - callback.onAvailable(network) - assertEquals(0, ready) - callback.onLinkPropertiesChanged(network, properties("::1")) - assertEquals(0, ready) - callback.onLinkPropertiesChanged(network, properties("192.168.4.20")) - assertEquals(1, ready); assertTrue(manager.uiState.value.networkReady) - manager.disconnect() - callback.onLinkPropertiesChanged(network, properties("192.168.4.20")) - callback.onUnavailable(); callback.onLost(network) - assertEquals(1, ready); assertEquals(0, lost) - assertEquals(WifiApPhase.IDLE, manager.uiState.value.phase) - verify(exactly = 1) { connectivity.unregisterNetworkCallback(callback) } - } - @Test fun wrongSubnetTimeoutAndDeniedPasswordCannotStartTcp() { - manager.connect(device, "bad") - assertTrue(callbacks.isEmpty()) - manager.connect(device, "12345678") - val callback = callbacks.single(); val network = mockk() - callback.onLinkPropertiesChanged(network, properties("127.0.0.1")) - timeouts.last().run() - assertEquals(WifiApPhase.ERROR, manager.uiState.value.phase) - assertEquals(0, ready); assertEquals(1, lost) - callback.onLinkPropertiesChanged(network, properties("192.168.4.20")) - assertEquals(0, ready) - } - @Test fun unavailableAndLostReleaseOnlyTheCurrentNetwork() { - manager.connect(device, "12345678") - val stale = callbacks.single() - manager.disconnect(); manager.connect(device, "12345678") - val current = callbacks.last(); val network = mockk() - stale.onUnavailable(); stale.onLinkPropertiesChanged(network, properties("192.168.4.30")) - assertEquals(0, ready); assertEquals(WifiApPhase.CONNECTING, manager.uiState.value.phase) - current.onAvailable(network); current.onLinkPropertiesChanged(network, properties("192.168.4.20")) - current.onLost(mockk()) // An unrelated Network must not kill this session. - assertTrue(manager.uiState.value.networkReady) - current.onLost(network) - assertEquals(1, lost); assertFalse(manager.uiState.value.networkReady) - current.onUnavailable(); assertEquals(1, lost) - } - @Test fun losingTheEquipmentSubnetEndsTheLease() { - manager.connect(device, "12345678") - val current = callbacks.single(); val network = mockk() - current.onLinkPropertiesChanged(network, properties("192.168.4.20")) - current.onLinkPropertiesChanged(network, properties("::1")) - assertEquals(1, ready); assertEquals(1, lost) - assertFalse(manager.uiState.value.networkReady) - } - @Test fun permissionDenialNeverRequestsAnAp() { - every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_DENIED - manager.connect(device, "12345678") - assertFalse(manager.uiState.value.permissionGranted) - assertTrue(callbacks.isEmpty()); assertEquals(0, ready) - } - @Test fun newLeaseInvalidatesOldTransportBeforeDhcpAndRepeatedPropertiesDoNotReconnect() { - val events = mutableListOf() - val owner = WifiApManager(context, { events += "ready" }, { events += "lost" }, sdk = 34, - onConnecting = { events += "prepare" }) - try { - owner.connect(device, "12345678") - assertEquals(listOf("prepare"), events) - val first = callbacks.last(); val network = mockk() - first.onLinkPropertiesChanged(network, properties("192.168.4.20")) - first.onLinkPropertiesChanged(network, properties("192.168.4.20")) - assertEquals(listOf("prepare", "ready"), events) - owner.connect(device.copy(ssid = "DCT_2"), "12345678") - assertEquals(listOf("prepare", "ready", "prepare", "lost"), events) - first.onLost(network) - assertEquals(WifiApPhase.CONNECTING, owner.uiState.value.phase) - } finally { owner.close() } - } - @Test fun oldAndroidUsesSettingsFallbackWithoutRequestingUnsupportedApi() { - val old = WifiApManager(context, { ready++ }, { lost++ }, sdk = 28) - try { - old.connect(device, "12345678") - assertTrue(callbacks.isEmpty()) - assertTrue(old.uiState.value.error!!.contains("Android 10 미만")) - } finally { old.close() } - } - @Test fun currentNetworkFallbackRejectsCellularAndAmbiguousWifiPaths() { - val cellular = mockk(); val wifiNetwork = mockk() - val cellularCaps = mockk(); val wifiCaps = mockk() - every { cellularCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false - every { wifiCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true - every { wifiCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false - every { wifiCaps.transportInfo } returns null - every { connectivity.getNetworkCapabilities(cellular) } returns cellularCaps - every { connectivity.getNetworkCapabilities(wifiNetwork) } returns wifiCaps - every { connectivity.allNetworks } returns arrayOf(cellular) - manager.connectCurrent() - assertEquals(0, ready) - every { connectivity.getLinkProperties(wifiNetwork) } returns properties("192.168.1.20") - every { connectivity.allNetworks } returns arrayOf(cellular, wifiNetwork) - manager.connectCurrent() - assertEquals(1, ready) - assertTrue(manager.uiState.value.networkReady) - assertEquals("192.168.1.20", manager.currentRoute().localAddress.hostAddress) - manager.disconnect() - every { connectivity.getLinkProperties(wifiNetwork) } returns properties("192.168.4.20") - every { connectivity.allNetworks } returns arrayOf(wifiNetwork, wifiNetwork) - manager.connectCurrent() - assertEquals(1, ready) - assertFalse(manager.uiState.value.networkReady) - } - - @Test fun configuredPcTargetUsesLanAndSavingDoesNotReconnectOnDhcpRefresh() { - var endpoint = WifiApEndpoint("192.168.1.100", 6000) - val owner = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34, - endpointProvider = { endpoint }) - try { - owner.connect(device, "12345678") - val callback = callbacks.last(); val network = mockk() - callback.onLinkPropertiesChanged(network, properties("192.168.1.20")) - val original = owner.currentRoute() - original.requireTarget("192.168.1.100", 6000) - endpoint = endpoint.copy(port = 7000) - callback.onLinkPropertiesChanged(network, properties("192.168.1.20")) - assertEquals(1, ready); assertEquals(0, lost) - assertFalse(original.samePath(owner.currentRoute())) - owner.currentRoute().requireTarget("192.168.1.100", 7000) - } finally { owner.close() } - } - - @Test fun currentNetworkCannotAdoptWifiBackedVpnOrIpv6OnlyWifi() { - val network = mockk(); val capabilities = mockk() - every { connectivity.allNetworks } returns arrayOf(network) - every { connectivity.getNetworkCapabilities(network) } returns capabilities - every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true - every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns true - every { capabilities.transportInfo } returns null - every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20") - manager.connectCurrent() - assertEquals(0, ready) - every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false - every { connectivity.getLinkProperties(network) } returns properties("::1") - manager.connectCurrent() - assertEquals(0, ready) - assertFalse(manager.uiState.value.networkReady) - } - - private fun wifiCapabilities(name: String?): NetworkCapabilities { - val info = mockk(); every { info.ssid } returns name - val capabilities = mockk() - every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true - every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false - every { capabilities.transportInfo } returns info - return capabilities - } - - @Test fun existingSystemWifiIsShownWithoutRequestingAnApOrStartingTcp() { - val network = mockk() - every { connectivity.allNetworks } returns arrayOf(network) - every { connectivity.getNetworkCapabilities(network) } returns wifiCapabilities("\"Office Wi-Fi\"") - every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20") - manager.start(); manager.start() - assertEquals(listOf(ConnectedWifiNetwork(network.toString(), "Office Wi-Fi", "192.168.1.20")), - manager.uiState.value.connectedNetworks) - assertEquals(0, ready); assertEquals(0, lost) - assertFalse(manager.uiState.value.networkReady) - assertEquals(WifiApPhase.IDLE, manager.uiState.value.phase) - verify(exactly = 0) { connectivity.requestNetwork(any(), any()) } - verify(exactly = 1) { connectivity.registerNetworkCallback(any(), any()) } - } - - @Test fun systemWifiObserverUpdatesDhcpAndLossWithoutOwningTheTransportLease() { - manager.start() - val observer = callbacks.single(); val network = mockk() - observer.onCapabilitiesChanged(network, wifiCapabilities("\"DCT\"")) - observer.onLinkPropertiesChanged(network, properties("192.168.4.30")) - assertEquals("192.168.4.30", manager.uiState.value.connectedNetworks.single().localAddress) - verify(exactly = 0) { connectivity.getNetworkCapabilities(network) } - verify(exactly = 0) { connectivity.getLinkProperties(network) } - manager.disconnect() - assertEquals("DCT", manager.uiState.value.connectedNetworks.single().ssid) - verify(exactly = 0) { connectivity.unregisterNetworkCallback(observer) } - observer.onLost(network) - assertTrue(manager.uiState.value.connectedNetworks.isEmpty()) - assertEquals(0, ready); assertEquals(0, lost) - manager.close(); manager.close() - observer.onCapabilitiesChanged(network, wifiCapabilities("DCT")) - assertTrue(manager.uiState.value.connectedNetworks.isEmpty()) - verify(exactly = 1) { connectivity.unregisterNetworkCallback(observer) } - } - - @Test fun redactedWifiNamesAreNotGuessedAndPermissionRevocationClearsCachedNames() { - val network = mockk() - every { connectivity.allNetworks } returns arrayOf(network) - every { connectivity.getNetworkCapabilities(network) } returns wifiCapabilities(WifiManager.UNKNOWN_SSID) - every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20") - every { wifi.connectionInfo } returns null - manager.start() - assertNull(manager.uiState.value.connectedNetworks.single().ssid) - assertEquals("연결된 Wi-Fi", manager.uiState.value.connectedNetworks.single().displayName) - callbacks.single().onCapabilitiesChanged(network, wifiCapabilities("Office")) - manager.refresh() - assertEquals("Office", manager.uiState.value.connectedNetworks.single().ssid) - every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_DENIED - manager.refresh() - assertNull(manager.uiState.value.connectedNetworks.single().ssid) - callbacks.single().onCapabilitiesChanged(network, wifiCapabilities("Office")) - assertNull(manager.uiState.value.connectedNetworks.single().ssid) - assertEquals(0, ready) - } - - @Test fun passiveSnapshotExcludesCellularAndWifiBackedVpnAndSupportsOldAndroid() { - val network = mockk(); val vpn = mockk(); val cellular = mockk() - val caps = wifiCapabilities(null) - val vpnCaps = wifiCapabilities("VPN") - every { vpnCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns true - val cellularCaps = mockk() - every { cellularCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false - every { connectivity.allNetworks } returns arrayOf(vpn, cellular, network) - every { connectivity.getNetworkCapabilities(network) } returns caps - every { connectivity.getNetworkCapabilities(vpn) } returns vpnCaps - every { connectivity.getNetworkCapabilities(cellular) } returns cellularCaps - every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20") - val info = mockk(); every { info.ssid } returns "\"Legacy AP\"" - every { wifi.connectionInfo } returns info - val old = WifiApManager(context, { ready++ }, { lost++ }, sdk = 28) - try { - old.start() - assertEquals("Legacy AP", old.uiState.value.connectedNetworks.single().ssid) - verify(exactly = 0) { caps.transportInfo } - assertEquals(0, ready); assertFalse(old.uiState.value.networkReady) - } finally { old.close() } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiApTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiApTest.kt deleted file mode 100644 index ff2692f..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WifiApTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.dct.app.tests - -import android.Manifest -import android.net.Network -import com.dct.app.data.communication.* -import io.mockk.* -import kotlinx.coroutines.* -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.Socket -import java.net.SocketAddress -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import javax.net.SocketFactory - -class WifiApTest { - private fun ip(value: String) = InetAddress.getByName(value) - @Test fun fixedEndpointAndScanPermissionsMatchTheEquipmentContract() { - assertEquals("192.168.4.1", DctWifiAp.HOST); assertEquals(5000, DctWifiAp.PORT) - assertTrue(DctWifiAp.runtimePermissions(34).contains(Manifest.permission.ACCESS_FINE_LOCATION)) - assertTrue(DctWifiAp.runtimePermissions(34).contains(Manifest.permission.NEARBY_WIFI_DEVICES)) - assertFalse(DctWifiAp.runtimePermissions(32).contains(Manifest.permission.NEARBY_WIFI_DEVICES)) - listOf("192.168.4.2", "192.168.4.254").forEach { assertTrue(DctWifiAp.isEquipmentSubnet(ip(it))) } - listOf("192.168.4.0", "192.168.4.1", "192.168.4.255", "192.168.49.2", "10.0.0.2", "::1") - .forEach { assertFalse(DctWifiAp.isEquipmentSubnet(ip(it))) } - } - @Test fun securityAndPasswordsDoNotSilentlyDowngradeUnsupportedAps() { - assertEquals(WifiApSecurity.WPA2, WifiApSecurity.fromCapabilities("[WPA2-PSK-CCMP][SAE][ESS]")) - assertEquals(WifiApSecurity.WPA3, WifiApSecurity.fromCapabilities("[SAE][ESS]")) - assertEquals(WifiApSecurity.OPEN, WifiApSecurity.fromCapabilities("[ESS]")) - for (capabilities in listOf("[WEP]", "[EAP]", "[OWE]")) - assertEquals(WifiApSecurity.UNSUPPORTED, WifiApSecurity.fromCapabilities(capabilities)) - assertFalse(WifiApSecurity.WPA2.acceptsPassword("1234567")) - assertTrue(WifiApSecurity.WPA2.acceptsPassword("12345678")) - assertFalse(WifiApSecurity.WPA2.acceptsPassword("a".repeat(64))) - assertFalse(WifiApSecurity.WPA2.acceptsPassword("1234567\n")) - assertFalse(WifiApSecurity.UNSUPPORTED.acceptsPassword("password")) - } - @Test fun apRouteRejectsWrongPortsHostsAndSubnets() { - val route = WifiApRoute(mockk(), ip("192.168.4.20"), "DCT") - route.requireTarget(DctWifiAp.HOST, DctWifiAp.PORT) - assertThrows(IOException::class.java) { route.requireTarget(DctWifiAp.HOST, 6000) } - assertThrows(IOException::class.java) { route.requireTarget("192.168.49.1", 5000) } - assertThrows(IOException::class.java) { route.copy(localAddress = ip("0.0.0.0")).requireTarget(DctWifiAp.HOST, 5000) } - val pc = route.copy(localAddress = ip("192.168.1.20"), endpoint = WifiApEndpoint("192.168.1.100", 6000)) - pc.requireTarget("192.168.1.100", 6000) - assertThrows(IOException::class.java) { pc.requireTarget("192.168.1.100", 7000) } - } - - @Test fun editableEndpointValidatesNormalizesAndRejectsInvalidTargets() { - assertEquals(WifiApEndpoint("192.168.1.100", 6000), - WifiApEndpoint.fromInput(WifiConnectionInput(" 192.168.001.100 ", " 6000 "))) - for (host in listOf("", "pc.local", "256.1.2.3", "0.0.0.0", "127.0.0.1", "224.0.0.1", "255.255.255.255", "::1")) - assertNull(WifiApEndpoint.fromInput(WifiConnectionInput(host, "6000")), host) - for (port in listOf("", "0", "65536", "-1", "abc")) - assertNull(WifiApEndpoint.fromInput(WifiConnectionInput("192.168.1.100", port)), port) - } - - private class TestSocket(private val address: InetAddress) : Socket() { - val reading = CountDownLatch(1); val closed = CountDownLatch(1) - var target: InetSocketAddress? = null - var source: InetSocketAddress? = null - override fun bind(bindpoint: SocketAddress?) { source = bindpoint as InetSocketAddress } - override fun connect(endpoint: SocketAddress?, timeout: Int) { target = endpoint as InetSocketAddress } - override fun setTcpNoDelay(on: Boolean) {} - override fun setSoTimeout(timeout: Int) {} - override fun getLocalAddress(): InetAddress = address - override fun getInetAddress(): InetAddress? = target?.address - override fun getPort(): Int = target?.port ?: 0 - override fun getOutputStream() = ByteArrayOutputStream() - override fun getInputStream() = object : java.io.InputStream() { - override fun read(): Int { reading.countDown(); closed.await(5, TimeUnit.SECONDS); return -1 } - override fun read(bytes: ByteArray): Int = read() - } - override fun close() { super.close(); closed.countDown() } - } - @Test fun socketUsesOnlyTheSelectedApNetworkAndFixedEndpoint() = runBlocking { - val local = ip("192.168.4.20") - val socket = TestSocket(local) - val factory = mockk(); val network = mockk() - every { network.socketFactory } returns factory - every { factory.createSocket() } returns socket - val manager = WifiSocketManager() - manager.setWifiDirectRouteProvider { throw AssertionError("P2P must not be queried in AP mode") } - manager.setWifiApRouteProvider { WifiApRoute(network, local, "DCT") } - try { - val result = CompletableDeferred() - manager.connect(DctWifiAp.HOST, DctWifiAp.PORT, { result.complete(null) }, { result.complete(it) }) - assertNull(withTimeout(3000) { result.await() }) - assertTrue(manager.isConnected.value) - assertEquals(WifiApEndpoint(DctWifiAp.HOST, DctWifiAp.PORT), manager.connectedEndpoint.value) - assertEquals(InetSocketAddress(DctWifiAp.HOST, DctWifiAp.PORT), socket.target) - assertEquals(InetSocketAddress(local, 0), socket.source) - verify(exactly = 1) { factory.createSocket() } - } finally { manager.close() } - } - @Test fun missingOrChangedApRouteCannotFallBackToAnUnboundSocket() = runBlocking { - val manager = WifiSocketManager() - manager.setWifiApRouteProvider { throw IOException("no AP") } - try { - val failure = CompletableDeferred() - manager.connect(DctWifiAp.HOST, DctWifiAp.PORT, { fail("must not connect") }, { failure.complete(it) }) - assertEquals("no AP", withTimeout(3000) { failure.await() }) - assertFalse(manager.isConnected.value) - assertNull(manager.connectedEndpoint.value) - val local = ip("192.168.4.20"); val network = mockk(); val replacement = mockk() - val factory = mockk(); val socket = TestSocket(local) - every { network.socketFactory } returns factory; every { factory.createSocket() } returns socket - var queries = 0 - manager.setWifiApRouteProvider { WifiApRoute(if (++queries == 1) network else replacement, local, "DCT") } - val changed = CompletableDeferred() - manager.connect(DctWifiAp.HOST, DctWifiAp.PORT, { fail("stale AP") }, { changed.complete(it) }) - assertTrue(withTimeout(3000) { changed.await() }.contains("경로가 변경")) - assertTrue(socket.isClosed) - assertFalse(manager.isConnected.value) - } finally { manager.close() } - } - - @Test fun pcLanSocketUsesEditedPortAndRejectsPortChangeDuringConnection() = runBlocking { - val local = ip("192.168.1.20"); val network = mockk() - val factory = mockk(); every { network.socketFactory } returns factory - var socket = TestSocket(local); every { factory.createSocket() } answers { socket } - val manager = WifiSocketManager() - var endpoint = WifiApEndpoint("192.168.1.100", 6000) - manager.setWifiApRouteProvider { WifiApRoute(network, local, "PC LAN", endpoint) } - try { - val first = CompletableDeferred() - manager.connect(endpoint.host, endpoint.port, { first.complete(null) }, { first.complete(it) }) - assertNull(withTimeout(3000) { first.await() }) - assertEquals(InetSocketAddress("192.168.1.100", 6000), socket.target) - assertEquals(endpoint, manager.connectedEndpoint.value) - assertEquals(InetSocketAddress(local, 0), socket.source) - manager.disconnectSuspend() - assertNull(manager.connectedEndpoint.value) - socket = TestSocket(local) - var queries = 0 - manager.setWifiApRouteProvider { - WifiApRoute(network, local, "PC LAN", if (++queries == 1) endpoint else endpoint.copy(port = 7000)) - } - val changed = CompletableDeferred() - manager.connect(endpoint.host, endpoint.port, { fail("stale port") }, { changed.complete(it) }) - assertTrue(withTimeout(3000) { changed.await() }.contains("경로가 변경")) - assertTrue(socket.isClosed) - assertFalse(manager.isConnected.value) - assertNull(manager.connectedEndpoint.value) - } finally { manager.close() } - } -} 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 deleted file mode 100644 index f955f1a..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.communication.WifiSocketManager -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Assertions.* -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 - .getDeclaredMethod("attachSocket", Socket::class.java, String::class.java, Long::class.javaPrimitiveType) - .also { it.isAccessible = true }.invoke(manager, socket, "late", generation) as Boolean - - @Test fun lateSocketFromOlderConnectAttemptIsClosedWithoutPublishingConnection() { - val manager = WifiSocketManager() - val old = next(manager) - next(manager) - val stale = Socket() // Unconnected socket: no network activity. - assertFalse(attach(manager, stale, old)) - assertTrue(stale.isClosed) - assertFalse(manager.isConnected.value) - assertNull(manager.connectedHost.value) - assertNull(manager.connectedEndpoint.value) - } - - @Test fun disconnectInvalidatesInFlightConnectionAttempt() = runBlocking { - val manager = WifiSocketManager() - val old = next(manager) - manager.disconnectSuspend() - val stale = Socket() - assertFalse(attach(manager, stale, old)) - assertTrue(stale.isClosed) - assertFalse(manager.isConnected.value) - assertNull(manager.connectedEndpoint.value) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt deleted file mode 100644 index c67eaba..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.dct.app.tests - -import android.content.SharedPreferences -import com.dct.app.data.communication.WifiConnectionInput -import com.dct.app.data.communication.WifiConnectionPreferences -import io.mockk.* -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class WifiConnectionPreferencesTest { - @Test fun savedPcEndpointRestoresAndBlankOrInvalidDraftUsesEquipmentDefault() { - val store = WifiConnectionPreferences(preferences()) - val pc = com.dct.app.data.communication.WifiApEndpoint("192.168.1.100", 6000) - store.save(pc.asInput()) - assertEquals(pc, com.dct.app.data.communication.WifiApEndpoint.fromInput(store.load())) - store.save(WifiConnectionInput("not-an-ip", "bad-port")) - assertNull(com.dct.app.data.communication.WifiApEndpoint.fromInput(store.load())) - assertEquals(com.dct.app.data.communication.WifiApEndpoint(), - com.dct.app.data.communication.WifiApEndpoint.fromInput(store.load()) - ?: com.dct.app.data.communication.WifiApEndpoint()) - } - private fun preferences(): SharedPreferences { - val values = mutableMapOf() - val pending = mutableMapOf() - val integers = mutableMapOf() - val pendingIntegers = mutableMapOf() - val prefs = mockk() - val editor = mockk() - every { prefs.getString(any(), any()) } answers { values[firstArg()] ?: secondArg() } - every { prefs.getInt(any(), any()) } answers { integers[firstArg()] ?: secondArg() } - every { prefs.edit() } returns editor - every { editor.putString(any(), any()) } answers { pending[firstArg()] = secondArg(); editor } - every { editor.putInt(any(), any()) } answers { pendingIntegers[firstArg()] = secondArg(); editor } - every { editor.apply() } answers { - values.putAll(pending); pending.clear() - integers.putAll(pendingIntegers); pendingIntegers.clear() - } - return prefs - } - - @Test fun firstUseHasNoDefaultIpOrPortAndCannotConnect() { - val input = WifiConnectionPreferences(preferences()).load() - assertEquals("", input.ip) - assertEquals("", input.port) - assertFalse(input.canConnect) - assertNull(input.portNumber) - } - - @Test fun directPortDefaultsTo5000AndPersistsIndependentlyOfApSettings() { - val prefs=preferences() - val store=WifiConnectionPreferences(prefs) - assertEquals(5000,store.loadDirectPort()) - val ap=WifiConnectionInput("192.168.200.9","5000") - store.save(ap) - store.saveDirectPort(6000) - assertEquals(6000,WifiConnectionPreferences(prefs).loadDirectPort()) - assertEquals(ap,WifiConnectionPreferences(prefs).load()) - store.save(WifiConnectionInput()) - assertEquals(6000,store.loadDirectPort()) - for (port in listOf(1,65535)) { - store.saveDirectPort(port) - assertEquals(port,WifiConnectionPreferences(prefs).loadDirectPort()) - } - for (port in listOf(0,-1,65536)) assertThrows(IllegalArgumentException::class.java) { store.saveDirectPort(port) } - assertEquals(65535,store.loadDirectPort()) - } - - @Test fun invalidStoredDirectPortUses5000() { - val prefs=preferences() - for (port in listOf(0,-1,65536)) { - every { prefs.getInt("direct_port",any()) } returns port - assertEquals(5000,WifiConnectionPreferences(prefs).loadDirectPort()) - } - } - - @Test fun lastDraftSurvivesNewStoreAndDoesNotRequireSuccessfulConnection() { - val prefs = preferences() - val input = WifiConnectionInput("192.168.0.", "") - WifiConnectionPreferences(prefs).save(input) - assertEquals(input, WifiConnectionPreferences(prefs).load()) - val complete = WifiConnectionInput("192.168.0.15", "7777") - WifiConnectionPreferences(prefs).save(complete) - assertEquals(complete, WifiConnectionPreferences(prefs).load()) - } - - @Test fun clearingFieldsRemembersEmptyValuesInsteadOfRestoringDefaults() { - val prefs = preferences() - val store = WifiConnectionPreferences(prefs) - store.save(WifiConnectionInput("192.168.4.1", "6000")) - store.save(WifiConnectionInput()) - assertEquals(WifiConnectionInput(), WifiConnectionPreferences(prefs).load()) - } - - @Test fun invalidPortsNeverFallBackTo6000() { - for (port in listOf("", "abc", "0", "65536", "-1", "1.5", "99999999999999999")) { - val input = WifiConnectionInput("192.168.1.2", port) - assertNull(input.portNumber, port) - assertFalse(input.canConnect, port) - } - for (port in listOf("1", "6000", "65535")) assertTrue(WifiConnectionInput("192.168.1.2", port).canConnect) - } - - @Test fun ipv4ValidationAndWhitespaceHandling() { - for (ip in listOf("", "192.168.1", "256.1.2.3", "1..2.3", "1.2.3.a", "1.2.3.4.5")) { - assertFalse(WifiConnectionInput(ip, "6000").canConnect, ip) - } - assertTrue(WifiConnectionInput(" 192.168.10.22 ", " 7777 ").canConnect) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiDirectManagerStateTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiDirectManagerStateTest.kt deleted file mode 100644 index cf30919..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WifiDirectManagerStateTest.kt +++ /dev/null @@ -1,229 +0,0 @@ -package com.dct.app.tests - -import android.content.Context -import android.content.pm.PackageManager -import android.location.LocationManager -import android.net.wifi.WifiManager -import android.net.wifi.p2p.* -import com.dct.app.data.communication.* -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.* -import org.junit.jupiter.api.* -import org.junit.jupiter.api.Assertions.* -import java.net.InetAddress - -/** Deliberately reorder OS callbacks: API acceptance is not a completed link transition. */ -class WifiDirectManagerStateTest { - private val dispatcher = StandardTestDispatcher() - private lateinit var controller: WifiDirectManager - private lateinit var api: WifiP2pManager - private val infoRequests = mutableListOf() - private val groupRequests = mutableListOf() - private val peerRequests = mutableListOf() - private val discoveryRequests = mutableListOf() - private val stopDiscoveryRequests = mutableListOf() - private val connectRequests = mutableListOf() - private val cancelRequests = mutableListOf() - private val removeRequests = mutableListOf() - - @BeforeEach fun setup() { - Dispatchers.setMain(dispatcher) - val context = mockk(relaxed = true) - val pm = mockk(relaxed = true) - val wifi = mockk(relaxed = true) - val location = mockk(relaxed = true) - api = mockk(relaxed = true) - every { context.applicationContext } returns context - every { context.packageManager } returns pm - every { pm.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT) } returns true - every { context.checkPermission(any(), any(), any()) } returns PackageManager.PERMISSION_GRANTED - every { context.getSystemService(WifiP2pManager::class.java) } returns api - every { context.getSystemService(android.net.ConnectivityManager::class.java) } returns null - every { context.getSystemService(WifiManager::class.java) } returns wifi - every { context.getSystemService(LocationManager::class.java) } returns location - every { wifi.isWifiEnabled } returns true - every { location.isProviderEnabled(any()) } returns true - every { api.initialize(any(), any(), any()) } returns mockk(relaxed = true) - every { api.requestConnectionInfo(any(), capture(infoRequests)) } just Runs - every { api.requestGroupInfo(any(), capture(groupRequests)) } just Runs - every { api.requestPeers(any(), capture(peerRequests)) } just Runs - every { api.discoverPeers(any(), capture(discoveryRequests)) } just Runs - every { api.stopPeerDiscovery(any(), capture(stopDiscoveryRequests)) } just Runs - every { api.connect(any(), any(), capture(connectRequests)) } just Runs - every { api.cancelConnect(any(), capture(cancelRequests)) } just Runs - every { api.removeGroup(any(), capture(removeRequests)) } just Runs - controller = WifiDirectManager(context) - controller.start() - dispatcher.scheduler.runCurrent() - respond(false) - } - - @AfterEach fun teardown() { - if (::controller.isInitialized) controller.close() - Dispatchers.resetMain() - unmockkAll() - } - - private fun info(formed: Boolean) = WifiP2pInfo().apply { - groupFormed = formed - isGroupOwner = false - groupOwnerAddress = if (formed) InetAddress.getByName("192.168.137.1") else null - } - private fun group() = mockk(relaxed = true).also { - every { it.`interface` } returns "p2p-test" - } - private fun respond(formed: Boolean) { - infoRequests.last().onConnectionInfoAvailable(info(formed)) - groupRequests.last().onGroupInfoAvailable(if (formed) group() else null) - } - private fun device() = WifiP2pDevice().apply { deviceName = "PC"; deviceAddress = "02:00:00:00:00:01"; status = WifiP2pDevice.AVAILABLE } - - @Test fun discoveryAcceptanceDoesNotInventPeersOrCompleteDiscovery() { - controller.discoverPeers(); dispatcher.scheduler.runCurrent() - discoveryRequests.last().onSuccess() - assertTrue(controller.uiState.value.discovering) - assertTrue(controller.uiState.value.peers.isEmpty()) - controller.discoverPeers(); dispatcher.scheduler.runCurrent() - assertEquals(1, discoveryRequests.size) - } - - @Test fun discoveryHasBoundedStopEvenWithoutBroadcasts() { - controller.discoverPeers(); dispatcher.scheduler.runCurrent() - dispatcher.scheduler.advanceTimeBy(20_001); dispatcher.scheduler.runCurrent() - verify(exactly = 1) { api.stopPeerDiscovery(any(), any()) } - } - - @Test fun connectAcceptanceWaitsForGroupInfo() { - controller.connect(device()); dispatcher.scheduler.runCurrent() - respond(false) - connectRequests.last().onSuccess() - assertEquals(WifiDirectPhase.CONNECTING, controller.uiState.value.phase) - assertNull(controller.uiState.value.link) - respond(true) - assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) - assertEquals("192.168.137.1", controller.uiState.value.link!!.info.groupOwnerAddress.hostAddress) - } - - @Test fun dialogDiscoveryCleanupDoesNotInterruptConnectionWatchdog() { - controller.connect(device()); dispatcher.scheduler.runCurrent(); respond(false) - controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() - assertTrue(stopDiscoveryRequests.isEmpty()) - assertEquals(WifiDirectPhase.CONNECTING, controller.uiState.value.phase) - // Keep OS snapshots current throughout negotiation, as the real framework does. - repeat(46) { - dispatcher.scheduler.advanceTimeBy(1000); dispatcher.scheduler.runCurrent() - respond(false) - } - assertEquals(1, cancelRequests.size) - assertEquals(WifiDirectPhase.CANCELLING, controller.uiState.value.phase) - verify(exactly = 0) { api.removeGroup(any(), any()) } - } - - @Test fun staleDiscoveryFailureCannotReplaceNewConnectionBeforeGroupQueryCompletes() { - controller.discoverPeers(); dispatcher.scheduler.runCurrent() - controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() - val oldStop = stopDiscoveryRequests.last() - controller.connect(device()); dispatcher.scheduler.runCurrent() - oldStop.onFailure(WifiP2pManager.BUSY) - discoveryRequests.last().onFailure(WifiP2pManager.ERROR) - assertEquals(WifiDirectPhase.CONNECTING, controller.uiState.value.phase) - assertNull(controller.uiState.value.error) - respond(false) - connectRequests.last().onSuccess(); respond(true) - assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) - } - - @Test fun stopDiscoveryBusyKeepsConfirmedLinkAndDoesNotDisconnect() { - controller.refresh(); dispatcher.scheduler.runCurrent(); respond(true) - val link = controller.uiState.value.link - controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() - stopDiscoveryRequests.last().onFailure(WifiP2pManager.BUSY) - assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) - assertSame(link, controller.uiState.value.link) - assertNull(controller.uiState.value.error) - verify(exactly = 0) { api.removeGroup(any(), any()) } - } - - @Test fun stopDiscoveryTimeoutKeepsLinkAndIgnoresLateFailure() { - controller.discoverPeers(); dispatcher.scheduler.runCurrent() - controller.refresh(); dispatcher.scheduler.runCurrent(); respond(true) - controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() - dispatcher.scheduler.advanceTimeBy(2001); dispatcher.scheduler.runCurrent() - assertFalse(controller.uiState.value.discovering) - assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) - stopDiscoveryRequests.last().onFailure(WifiP2pManager.ERROR) - assertEquals(WifiDirectPhase.CONNECTED, controller.uiState.value.phase) - assertNull(controller.uiState.value.error) - } - - @Test fun discoveryBusyAllowsExplicitRetryWithoutRetryLoop() { - controller.discoverPeers(); dispatcher.scheduler.runCurrent() - discoveryRequests.last().onFailure(WifiP2pManager.BUSY) - assertEquals(WifiDirectPhase.IDLE, controller.uiState.value.phase) - assertFalse(controller.uiState.value.discovering) - assertTrue(controller.uiState.value.error!!.contains("다시 시도")) - dispatcher.scheduler.advanceTimeBy(20001); dispatcher.scheduler.runCurrent() - assertEquals(1, discoveryRequests.size) - controller.discoverPeers(); dispatcher.scheduler.runCurrent() - assertEquals(2, discoveryRequests.size) - } - - @Test fun discoveryCleanupPreservesCancellationAndRemovalUntilOsConfirmation() { - controller.connect(device()); dispatcher.scheduler.runCurrent(); respond(false) - controller.cancelConnect(); dispatcher.scheduler.runCurrent() - controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() - assertEquals(WifiDirectPhase.CANCELLING, controller.uiState.value.phase) - assertTrue(stopDiscoveryRequests.isEmpty()) - cancelRequests.last().onSuccess(); dispatcher.scheduler.runCurrent(); respond(true) - controller.disconnect(); dispatcher.scheduler.runCurrent() - controller.stopPeerDiscovery(); dispatcher.scheduler.runCurrent() - assertEquals(WifiDirectPhase.DISCONNECTING, controller.uiState.value.phase) - assertTrue(stopDiscoveryRequests.isEmpty()) - removeRequests.last().onSuccess(); respond(false) - assertEquals(WifiDirectPhase.IDLE, controller.uiState.value.phase) - } - - @Test fun cancellationNeverRemovesAGroupAndLateConnectAckIsIgnored() { - controller.connect(device()); dispatcher.scheduler.runCurrent(); respond(false) - val connectAck = connectRequests.last() - controller.cancelConnect(); dispatcher.scheduler.runCurrent() - connectAck.onSuccess() - assertEquals(WifiDirectPhase.CANCELLING, controller.uiState.value.phase) - cancelRequests.last().onSuccess(); dispatcher.scheduler.runCurrent(); respond(false) - assertEquals(WifiDirectPhase.IDLE, controller.uiState.value.phase) - verify(exactly = 0) { api.removeGroup(any(), any()) } - } - - @Test fun removeAcceptanceRetainsGroupUntilOsConfirmsLoss() { - controller.refresh(); dispatcher.scheduler.runCurrent(); respond(true) - controller.disconnect(); dispatcher.scheduler.runCurrent() - removeRequests.last().onSuccess() - assertNotNull(controller.uiState.value.link) - assertEquals(WifiDirectPhase.DISCONNECTING, controller.uiState.value.phase) - respond(false) - assertNull(controller.uiState.value.link) - assertEquals(WifiDirectPhase.IDLE, controller.uiState.value.phase) - verify(exactly = 0) { api.cancelConnect(any(), any()) } - } - - @Test fun staleGroupCallbackCannotReplaceNewerConfirmedState() { - controller.refresh(); dispatcher.scheduler.runCurrent() - infoRequests.last().onConnectionInfoAvailable(info(true)) - val stale = groupRequests.last() - controller.refresh(); dispatcher.scheduler.runCurrent(); respond(false) - stale.onGroupInfoAvailable(group()) - assertNull(controller.uiState.value.link) - } - - @Test fun closingControllerDoesNotRemoveOsGroup() { - controller.close() - verify(exactly = 0) { api.removeGroup(any(), any()) } - } - - @Test fun permissionMatrixAndBusyMessageAreExplicit() { - assertEquals(listOf(android.Manifest.permission.ACCESS_FINE_LOCATION), WifiDirectRequirements.runtimePermissions(32).toList()) - assertEquals(listOf(android.Manifest.permission.NEARBY_WIFI_DEVICES), WifiDirectRequirements.runtimePermissions(33).toList()) - assertTrue(WifiDirectRequirements.error("검색", WifiP2pManager.BUSY).contains("다시 시도")) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiDirectRouteTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiDirectRouteTest.kt deleted file mode 100644 index 63aa806..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WifiDirectRouteTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.communication.WifiDirectRoute -import com.dct.app.data.communication.WifiDirectRouteNotReady -import com.dct.app.data.communication.WifiSocketManager -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.domain.model.protocol.DctPacket -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.first -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import java.io.IOException -import java.net.InetAddress -import java.net.ServerSocket -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.net.Socket -import java.net.SocketAddress -import javax.net.SocketFactory -import android.net.Network -import io.mockk.every -import io.mockk.mockk - -class WifiDirectRouteTest { - private fun ip(value: String) = InetAddress.getByName(value) - private fun loopbackRoute() = WifiDirectRoute("test-group-interface", ip("127.0.0.1"), ip("127.0.0.1"), false) - - @Test fun actualSubnetAndPrefixAreUsedInsteadOfFixedP2pRange() { - assertTrue(WifiDirectRoute.sameSubnet(ip("192.168.137.23"), ip("192.168.137.1"), 24)) - assertFalse(WifiDirectRoute.sameSubnet(ip("192.168.200.6"), ip("192.168.137.1"), 24)) - assertFalse(WifiDirectRoute.sameSubnet(ip("192.168.137.129"), ip("192.168.137.1"), 25)) - assertFalse(WifiDirectRoute.sameSubnet(ip("192.168.137.23"), ip("192.168.137.1"), 0)) - assertFalse(WifiDirectRoute.sameSubnet(ip("::1"), ip("127.0.0.1"), 24)) - } - - @Test fun staleOwnerAddressAndWrongRoleAreRejected() { - val route = WifiDirectRoute("swlan1", ip("192.168.137.23"), ip("192.168.137.1"), false) - route.requireClientTarget("192.168.137.1") - assertThrows(IOException::class.java) { route.requireClientTarget("192.168.200.16") } - assertThrows(IOException::class.java) { route.copy(isGroupOwner = true).requireClientTarget("192.168.137.1") } - } - - @Test fun networkObjectIsNotRequiredForBoundSocketAndBidirectionalDctPackets() = runBlocking { - val manager = WifiSocketManager() - manager.setWifiDirectRouteProvider { loopbackRoute() } - ServerSocket(0, 1, ip("127.0.0.1")).use { server -> - server.soTimeout = 3000 - try { - val result = CompletableDeferred() - manager.connect("127.0.0.1", server.localPort, - onSuccess = { result.complete(null) }, onFailure = { result.complete(it) }) - assertNull(withTimeout(3000) { result.await() }) - server.accept().use { peer -> - peer.soTimeout = 3000 - assertEquals(ip("127.0.0.1"), peer.inetAddress) - val request = DctPacket(0x00) - val bytes = DctPacketCodec().encode(request) - assertTrue(manager.sendCommand(request)) - assertArrayEquals(bytes, peer.getInputStream().readNBytes(bytes.size)) - val response = DctPacket(0x80.toByte()) - val received = async(start = CoroutineStart.UNDISPATCHED) { manager.packetStream.first() } - peer.getOutputStream().write(DctPacketCodec().encode(response)) - assertEquals(response, withTimeout(3000) { received.await() }) - } - } finally { manager.disconnectSuspend() } - } - } - - @Test fun missingGroupDoesNotFallBackToReachableOrdinarySocket() = runBlocking { - val manager = WifiSocketManager() - manager.setWifiDirectRouteProvider { throw WifiDirectRouteNotReady("group missing") } - ServerSocket(0, 1, ip("127.0.0.1")).use { server -> - try { - val result = CompletableDeferred() - manager.connect("127.0.0.1", server.localPort, - onSuccess = { result.complete(null) }, onFailure = { result.complete(it) }) - assertEquals("group missing", withTimeout(3000) { result.await() }) - assertFalse(manager.isConnected.value) - server.soTimeout = 150 - assertThrows(java.net.SocketTimeoutException::class.java) { server.accept().close() } - } finally { manager.disconnectSuspend() } - } - } - - @Test fun disconnectClosesSocketWhileConnectIsBlockedAndDoesNotReportFailure() = runBlocking { - val entered = CountDownLatch(1) - val closed = CountDownLatch(1) - val finished = CountDownLatch(1) - val callbacks = AtomicInteger() - val socket = object : Socket() { - override fun bind(bindpoint: SocketAddress?) {} - override fun connect(endpoint: SocketAddress?, timeout: Int) { - entered.countDown() - try { - check(closed.await(3, TimeUnit.SECONDS)) - throw IOException("closed during connect") - } finally { finished.countDown() } - } - override fun close() { super.close(); closed.countDown() } - } - val factory = mockk() - val network = mockk() - every { factory.createSocket() } returns socket - every { network.socketFactory } returns factory - val manager = WifiSocketManager() - manager.setWifiDirectRouteProvider { loopbackRoute().copy(network = network) } - try { - manager.connect("127.0.0.1", 6000, - onSuccess = { callbacks.incrementAndGet() }, onFailure = { callbacks.incrementAndGet() }) - assertTrue(entered.await(3, TimeUnit.SECONDS)) - manager.disconnectSuspend() - assertTrue(closed.await(1, TimeUnit.SECONDS)) - assertTrue(finished.await(1, TimeUnit.SECONDS)) - delay(50) - assertTrue(socket.isClosed) - assertFalse(manager.isConnected.value) - assertEquals(0, callbacks.get()) - } finally { manager.disconnectSuspend() } - } - - @Test fun changedGroupDuringConnectIsRejectedBeforePublishingConnected() = runBlocking { - val manager = WifiSocketManager() - val calls = AtomicInteger() - manager.setWifiDirectRouteProvider { - if (calls.incrementAndGet() == 1) loopbackRoute() - else loopbackRoute().copy(interfaceName = "replacement-group") - } - ServerSocket(0, 1, ip("127.0.0.1")).use { server -> - try { - val result = CompletableDeferred() - manager.connect("127.0.0.1", server.localPort, - onSuccess = { result.complete(null) }, onFailure = { result.complete(it) }) - assertNotNull(withTimeout(3000) { result.await() }) - assertFalse(manager.isConnected.value) - server.soTimeout = 3000 - server.accept().use { peer -> - peer.soTimeout = 3000 - assertEquals(-1, peer.getInputStream().read()) - } - } finally { manager.disconnectSuspend() } - } - } - - @Test fun ipReadinessRetryUsesTheRouteWhenItBecomesAvailable() = runBlocking { - val manager = WifiSocketManager() - val calls = AtomicInteger() - manager.setWifiDirectRouteProvider { - if (calls.incrementAndGet() < 2) throw WifiDirectRouteNotReady("IP pending") - loopbackRoute() - } - ServerSocket(0, 1, ip("127.0.0.1")).use { server -> - try { - val result = CompletableDeferred() - manager.startDualMode(false, "127.0.0.1", server.localPort, - onConnected = { _, _ -> result.complete(null) }, onFailure = { result.complete(it) }) - assertNull(withTimeout(4000) { result.await() }) - assertTrue(manager.isConnected.value) - server.soTimeout = 3000 - server.accept().close() - } finally { manager.disconnectSuspend() } - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiDualModeTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiDualModeTest.kt deleted file mode 100644 index 097e542..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WifiDualModeTest.kt +++ /dev/null @@ -1,206 +0,0 @@ -package com.dct.app.tests - -import android.net.wifi.p2p.WifiP2pDevice -import android.net.wifi.p2p.WifiP2pGroup -import android.net.wifi.p2p.WifiP2pInfo -import com.dct.app.core.common.DctResult -import com.dct.app.data.communication.* -import com.dct.app.domain.repository.DeviceConnectionState -import com.dct.app.domain.repository.PacketRepository -import com.dct.app.presentation.main.* -import io.mockk.* -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -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 -import java.net.InetAddress - -@Suppress("UNCHECKED_CAST") -class WifiDualModeTest { - private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } - private inner class Fixture : AutoCloseable { - val owner = MainViewModelTest().also { it.setUp() } - val vm = field(owner, "viewModel").get(owner) as MainViewModel - val repo = field(owner, "packetRepository").get(owner) as PacketRepository - val ui = field(vm, "_uiState").get(vm) as MutableStateFlow - val apState = field(vm, "_wifiApState").get(vm) as MutableStateFlow - val directState = MutableStateFlow(WifiDirectUiState(phase=WifiDirectPhase.IDLE, - enabled=true, permissionGranted=true, synchronizedWithOs=true)) - val direct = mockk(relaxed=true) - val ap = mockk(relaxed=true) - val socket = vm.wifiSocketManager - init { - every { direct.uiState } returns directState - every { ap.uiState } returns apState - field(vm, "registeredWifiApManager").set(vm, ap) - vm.registerWifiDirectManager(direct) - coEvery { repo.connect(any()) } answers { - (field(owner,"repositoryConnected").get(owner) as MutableStateFlow).value=true - (field(owner,"connectionState").get(owner) as MutableStateFlow).value=DeviceConnectionState.READY - DctResult.Success(Unit) - } - } - fun connectedDirect(ownerRole: Boolean = false): WifiDirectUiState { - val peer = WifiP2pDevice().apply { deviceName="DCT peer"; deviceAddress="aa:bb:cc:dd:ee:ff" } - val group = mockk { - every { getOwner() } returns peer - every { getClientList() } returns listOf(peer) - every { getInterface() } returns "p2p-test" - } - return directState.value.copy(phase=WifiDirectPhase.CONNECTED, - link=WifiDirectLink(WifiP2pInfo().apply { - groupFormed=true; isGroupOwner=ownerRole; groupOwnerAddress=InetAddress.getByName("192.168.77.1") - }, group)) - } - fun apReady() = WifiApUiState(phase=WifiApPhase.CONNECTED, enabled=true, permissionGranted=true, - localAddress="192.168.4.2", selectedSsid="DCT") - override fun close() { vm.scope.cancel(); owner.tearDown() } - } - - @ParameterizedTest @ValueSource(booleans=[false,true]) - fun directGroupFormationNeverStartsProtocolUntilExplicitConnect(ownerRole: Boolean) { - Fixture().use { f -> - assertEquals(WifiConnectionMode.TCP, f.vm.wifiConnectionMode.value) - assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) - f.directState.value = f.connectedDirect(ownerRole) - assertTrue(f.vm.canConnectCommunication.value) - verify(exactly=0) { f.socket.startDualMode(any(), any(), any(), any(), any(), any()) } - coVerify(exactly=0) { f.repo.connect(any()) } - var ready: ((Boolean,String)->Unit)? = null - every { f.socket.startDualMode(any(), any(), any(), any(), any(), any()) } answers { ready=arg(3) } - f.vm.connectCommunication() - f.vm.connectCommunication() - verify(exactly=1) { f.socket.startDualMode(ownerRole, "192.168.77.1", 5000, any(), any(), any()) } - coVerify(exactly=0) { f.repo.connect(any()) } - ready!!(ownerRole, "192.168.77.1") - coVerify(exactly=1) { f.repo.connect("192.168.77.1") } - coVerify(exactly=1) { f.repo.configureMeasurement(any()) } - } - } - - @Test fun modeChangesSelectOnlyTheirOwnRouteAndKeepApEndpoint() { - Fixture().use { f -> - assertTrue(f.vm.setWifiApEndpoint(WifiApEndpoint("192.168.200.9",5000))) - clearMocks(f.socket, answers=false) - assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) - verify { f.socket.setWifiApRouteProvider(null) } - verify { f.socket.setRecoveryNetworkProvider(null) } - verify { f.socket.setWifiDirectRouteProvider(isNull(inverse=true)) } - assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) - verify { f.socket.setWifiDirectRouteProvider(null) } - verify { f.socket.setWifiApRouteProvider(isNull(inverse=true)) } - assertEquals(WifiApEndpoint("192.168.200.9",5000), f.vm.wifiApEndpoint.value) - } - } - - @ParameterizedTest @ValueSource(booleans=[false,true]) - fun configuredDirectPortIsUsedByBothRolesAndLockedDuringCommunication(ownerRole: Boolean) { - Fixture().use { f -> - assertEquals(5000,f.vm.wifiDirectPort.value) - assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) - f.directState.value=f.connectedDirect(ownerRole) - for (port in listOf(0,65536,-1)) assertFalse(f.vm.setWifiDirectPort(port)) - assertTrue(f.vm.setWifiDirectPort(6000)) - assertEquals(WifiApEndpoint(),f.vm.wifiApEndpoint.value) - f.vm.connectCommunication() - verify(exactly=1) { f.socket.startDualMode(ownerRole,"192.168.77.1",6000,any(),any(),any()) } - assertFalse(f.vm.canEditWifiDirectPort.value) - assertFalse(f.vm.setWifiDirectPort(5000)) - f.vm.disconnectCommunication() - assertTrue(f.vm.setWifiDirectPort(5001)) - assertEquals(5001,f.vm.wifiDirectPort.value) - } - } - - @Test fun connectedMeasuringAndRecordingCannotChangeDirectPort() { - Fixture().use { f -> - for (ui in listOf(MainUiState(isConnected=true),MainUiState(isMeasuring=true), - MainUiState(recordingState=MeasurementRecordingState.RECORDING))) { - f.ui.value=ui - assertFalse(f.vm.setWifiDirectPort(6000)) - assertEquals(5000,f.vm.wifiDirectPort.value) - } - } - } - - @Test fun disconnectKeepsTheDirectGroupAndWifiReleaseRemovesOnlyThatGroup() { - Fixture().use { f -> - f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT) - f.directState.value=f.connectedDirect() - f.ui.value=f.ui.value.copy(isConnected=true, communicationType=CommunicationType.WIFI) - f.vm.disconnectCommunication() - verify(exactly=0) { f.direct.disconnect() } - verify(exactly=0) { f.ap.disconnect() } - coVerify(exactly=1) { f.repo.disconnect() } - assertTrue(f.vm.canConnectCommunication.value) - assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) - f.vm.disconnectCommunication(releaseWifiAp=true) - verify(exactly=1) { f.direct.disconnect() } - verify(exactly=0) { f.ap.disconnect() } - f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE) - assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) - } - } - - @Test fun inactiveDirectGroupLossNeverClosesApCommunication() { - Fixture().use { f -> - f.apState.value=f.apReady() - f.directState.value=f.connectedDirect() - f.ui.value=f.ui.value.copy(isConnected=true,communicationType=CommunicationType.WIFI) - f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE) - verify(exactly=0) { f.socket.invalidateConnection() } - coVerify(exactly=0) { f.repo.disconnect() } - assertTrue(f.vm.uiState.value.isConnected) - } - } - - @Test fun activeDirectGroupLossClosesItsTransportWithoutStartingAnother() { - Fixture().use { f -> - f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT) - f.directState.value=f.connectedDirect() - f.ui.value=f.ui.value.copy(isConnected=true,communicationType=CommunicationType.WIFI) - f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE) - verify(exactly=1) { f.socket.invalidateConnection() } - coVerify(exactly=1) { f.repo.disconnect() } - verify(exactly=0) { f.socket.startDualMode(any(),any(),any(),any(),any(),any()) } - assertFalse(f.vm.uiState.value.isConnected) - } - } - - @Test fun apModeAndDirectModeUseIndependentReadiness() { - Fixture().use { f -> - f.apState.value=f.apReady() - assertTrue(f.vm.canConnectCommunication.value) - assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) - f.apState.value=WifiApUiState() - assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) - assertFalse(f.vm.canConnectCommunication.value) - f.apState.value=f.apReady() - assertFalse(f.vm.canConnectCommunication.value) - f.directState.value=f.connectedDirect() - assertTrue(f.vm.canConnectCommunication.value) - f.directState.value=f.directState.value.copy(permissionGranted=false) - assertFalse(f.vm.canConnectCommunication.value) - f.vm.connectCommunication() - verify(exactly=0) { f.socket.startDualMode(any(),any(),any(),any(),any(),any()) } - } - } - - @Test fun measurementRecordingAndNegotiationBlockModeChanges() { - Fixture().use { f -> - for (state in listOf(MainUiState(isMeasuring=true), MainUiState(isCommunicationConnecting=true), - MainUiState(recordingState=MeasurementRecordingState.RECORDING),MainUiState(isConnected=true))) { - f.ui.value=state - assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) - } - f.ui.value=MainUiState() - assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) - f.directState.value=f.directState.value.copy(phase=WifiDirectPhase.CONNECTING) - assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) - f.vm.disconnectCommunication(releaseWifiAp=true) - verify(exactly=1) { f.direct.cancelConnect() } - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiSocketRecoveryTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiSocketRecoveryTest.kt deleted file mode 100644 index e9197aa..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WifiSocketRecoveryTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.data.communication.WifiSocketManager -import com.dct.app.data.protocol.DctPacketCodec -import com.dct.app.data.protocol.DctProtocolRequests -import kotlinx.coroutines.* -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import java.net.ServerSocket -import java.net.Socket - -class WifiSocketRecoveryTest { - @Test fun recoveryReopensTheSameEndpointAndOldOwnerCannotReplaceANewManualSession() = runBlocking { - val manager = WifiSocketManager() - ServerSocket(0).use { server -> - server.soTimeout = 3000 - val peers = mutableListOf() - suspend fun connect() { - val connected = CompletableDeferred() - manager.connect("127.0.0.1", server.localPort, - onSuccess = { connected.complete(Unit) }, - onFailure = { connected.completeExceptionally(IllegalStateException(it)) }) - peers += withContext(Dispatchers.IO) { server.accept() }.apply { soTimeout = 3000 } - withTimeout(5000) { connected.await() } - } - try { - connect() - val owner = manager.recoveryGeneration - val session = manager.currentSessionId - val endpoint = manager.recoveryEndpoint!! - manager.pauseForNetworkRecovery() - assertFalse(manager.isConnected.value) - assertEquals(owner, manager.recoveryGeneration) - assertEquals(endpoint, manager.recoveryEndpoint) - val recovery = async { manager.reconnectForRecovery(owner, 0, endpoint) } - peers += withContext(Dispatchers.IO) { server.accept() }.apply { soTimeout = 3000 } - assertTrue(withTimeout(5000) { recovery.await() }) - assertTrue(manager.currentSessionId > session) - assertEquals(owner, manager.recoveryGeneration) - val frame = DctPacketCodec().encode(DctProtocolRequests.possibleToConnect()) - assertTrue(manager.sendCommand(DctProtocolRequests.possibleToConnect())) - assertArrayEquals(frame, withContext(Dispatchers.IO) { peers.last().getInputStream().readNBytes(frame.size) }) - connect() // A manual connection takes ownership, even at the same address. - val newest = manager.currentSessionId - assertFalse(manager.reconnectForRecovery(owner, newest, endpoint)) - assertEquals(newest, manager.currentSessionId) - assertTrue(manager.isConnected.value) - assertTrue(manager.sendCommand(DctProtocolRequests.possibleToConnect())) - assertArrayEquals(frame, withContext(Dispatchers.IO) { peers.last().getInputStream().readNBytes(frame.size) }) - } finally { manager.close(); peers.forEach { it.close() } } - } - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WindowActivationGestureTest.kt b/Android/app/src/test/java/com/dct/app/tests/WindowActivationGestureTest.kt deleted file mode 100644 index 65b8a2c..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WindowActivationGestureTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.presentation.monitor.components.WindowActivationGesture -import com.dct.app.presentation.monitor.components.WorkspaceTouchNavigation -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class WindowActivationGestureTest { - @Test fun firstFingerDoesNotRaiseWindowAndSingleFingerTapDoes() { - val gate = WindowActivationGesture() - assertFalse(gate.shouldActivate(false, false)) - assertTrue(gate.shouldActivate(false, true)) - } - @Test fun twoFingerPanPinchOrTapNeverActivatesEvenOnTrailingRelease() { - val gate = WindowActivationGesture() - assertFalse(gate.shouldActivate(false, false)) - assertFalse(gate.shouldActivate(true, false)) - assertFalse(gate.shouldActivate(true, true)) - assertFalse(gate.shouldActivate(false, true)) - } - @Test fun establishedSingleFingerDragActivatesOnlyOnce() { - val gate = WindowActivationGesture() - assertTrue(gate.shouldActivate(false, true)) - assertFalse(gate.shouldActivate(false, true)) - assertFalse(gate.shouldActivate(true, true)) - } - @Test fun secondWindowCannotActivateWhenItsFirstContactIsAlreadyMultiTouch() { - val gate = WindowActivationGesture() - assertFalse(gate.shouldActivate(true, false)) - assertFalse(gate.shouldActivate(false, true)) - } - @Test fun nextSingleFingerGestureCanActivateAgain() { - WindowActivationGesture().shouldActivate(true, false) - assertTrue(WindowActivationGesture().shouldActivate(false, true)) - } - @Test fun sharedMultiTouchHistorySurvivesFingerLiftAndResetsPerSequence() { - val navigation = WorkspaceTouchNavigation() - navigation.observePointerCount(1) - assertFalse(navigation.hadMultiplePointers) - navigation.observePointerCount(2) - navigation.observePointerCount(1) - navigation.observePointerCount(0) - assertTrue(navigation.hadMultiplePointers) - navigation.reset() - assertFalse(navigation.hadMultiplePointers) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WindowPageHoverTrackerTest.kt b/Android/app/src/test/java/com/dct/app/tests/WindowPageHoverTrackerTest.kt deleted file mode 100644 index 2e540bc..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WindowPageHoverTrackerTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.presentation.mdi.WindowPageHoverTracker -import com.dct.app.presentation.mdi.windowDragOffset -import androidx.compose.ui.geometry.Offset -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class WindowPageHoverTrackerTest { - @Test fun placementPreservesGrabPointWithIndependentPageScrollAndDensity() { - val pointer = Offset(160f, 70f) - val grab = Offset(40f, 10f) - val scroll = Offset(800f, 400f) - val position = windowDragOffset(pointer, grab, scroll, 2f) - assertEquals(Offset(460f, 230f), position) - assertEquals(pointer, position * 2f - scroll + grab) - assertEquals(Offset(60f, 30f), windowDragOffset(pointer, grab, Offset.Zero, 2f)) - } - - @Test fun hoveringTabPlacesHeaderAtVisibleTopOfDestination() { - assertEquals(Offset(130f, 150f), windowDragOffset( - Offset(100f, -20f), Offset(40f, 10f), Offset(200f, 300f), 2f)) - assertEquals(Offset.Zero, windowDragOffset(Offset(-10f, -20f), Offset(40f, 10f), Offset.Zero, 2f)) - } - - @Test fun activatesOnceAfterSixHundredMilliseconds() { - val tracker = WindowPageHoverTracker() - assertNull(tracker.update("page_2", 1000)) - assertNull(tracker.update("page_2", 1599)) - assertEquals("page_2", tracker.update("page_2", 1600)) - assertNull(tracker.update("page_2", 5000)) - } - - @Test fun crossingTabsOrLeavingResetsDwell() { - val tracker = WindowPageHoverTracker() - tracker.update("page_2", 0) - assertNull(tracker.update("page_3", 500)) - assertNull(tracker.update("page_3", 600)) - assertNull(tracker.update(null, 1000)) - assertNull(tracker.update("page_3", 1200)) - assertNull(tracker.update("page_3", 1799)) - assertEquals("page_3", tracker.update("page_3", 1800)) - } - - @Test fun canContinueAcrossSeveralPagesAndReturn() { - val tracker = WindowPageHoverTracker() - tracker.update("page_2", 0) - assertEquals("page_2", tracker.update("page_2", 600)) - tracker.update(null, 601) // Now hovering the active page. - tracker.update("page_3", 700) - assertEquals("page_3", tracker.update("page_3", 1300)) - tracker.update("page_1", 1400) - assertEquals("page_1", tracker.update("page_1", 2000)) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WindowParityTest.kt b/Android/app/src/test/java/com/dct/app/tests/WindowParityTest.kt deleted file mode 100644 index dafafe1..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WindowParityTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.domain.model.mdb.* -import com.dct.app.domain.model.project.* -import com.dct.app.domain.model.formula.FormulaDefinition -import com.dct.app.domain.model.chart.* -import com.dct.app.domain.usecase.chart.ChartBatchRenderer -import com.dct.app.data.repository.* -import com.dct.app.presentation.monitor.components.* -import com.dct.app.presentation.signaltree.* -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class WindowParityTest { - private fun signal(name: String = "RPM", idx: Int = 1) = SignalDefinition(1, idx, name, - MdbAttrType.ATTR_2_UINT16, 0.1, 0.0, "", 0x123456, 0.0, 100.0) - - @Test fun hexPreservesFullWidthAndTwosComplementAndMissingValue() { - assertEquals("0x1234", signal().formatRawHex(0x1234)) - assertEquals("0xFFFF", signal().copy(attr = MdbAttrType.ATTR_3_INT16).formatRawHex(-1)) - assertEquals("0xFFFFFFFF", signal().copy(attr = MdbAttrType.ATTR_4_UINT32).formatRawHex(0xFFFFFFFF)) - assertEquals("0x80", signal().copy(attr = MdbAttrType.ATTR_1_INT8).formatRawHex(-128)) - assertEquals("N/A", signal().formatRawHex(null)) - } - - @Test fun alphaUsesSourcePrecisionAndNcRules() { - assertEquals("12.3", AlphaSignal("sig_1_1", signal()).value(mapOf("sig_1_1" to 12.34))) - assertEquals("12.340", AlphaSignal("formula_F", signal()).value(mapOf("formula_F" to 12.34))) - val sm = AlphaSignal("sm_01_01", signal().copy(unit = "V")) - assertEquals("12.34 V", sm.value(mapOf("sm_01_01" to 12.34))) - for (v in listOf(Double.NaN, Double.POSITIVE_INFINITY, -999999.0)) assertEquals("NC", sm.value(mapOf(sm.id to v))) - assertEquals("NC", sm.value(emptyMap())) - assertEquals("N/A", AlphaSignal("sig_1_1", signal()).value(emptyMap())) - } - - @Test fun a2lIsNotMistakenForCanAndRespectsFormat() { - val sig = signal().copy(adl = 0x1234, messageName = "A2L", messageId = 0x1234, measurementFormat = "%6.2f") - val entry = resolveAlphaSignals(listOf("a2l_4660"), emptyList(), emptyList(), listOf(sig), emptyList(), emptyList()).single() - assertEquals("a2l_4660", entry.id) - assertEquals("12.35", entry.value(mapOf(entry.id to 12.345, "can_1" to 90.0))) - assertEquals("1.234", sig.copy(measurementFormat = "%999999999f").formatMeasurementValue(1.234)) - } - - @Test fun alphaMenuAndBodyShareSensorAndFormulaNamesAndColors() { - val mod = DpcSensorModuleConfig(0, channels = listOf(DpcSensorModuleChannel(0, "Ambient", color = 0x0011FF))) - val formula = FormulaDefinition(name = "F", expression = "1+2", color = 0x0033FF) - val entries = resolveAlphaSignals(listOf("sm_01_01", "formula_F"), emptyList(), emptyList(), emptyList(), listOf(formula), listOf(mod)) - assertEquals(listOf("sm_01_01", "formula_F"), entries.map { it.id }) - assertEquals(listOf("Ambient", "F"), entries.map { it.definition.name }) - assertEquals(listOf(0x0011FFL, 0x0033FFL), entries.map { it.definition.color }) - val renamed = mod.copy(channels = listOf(mod.channels.single().copy(name = "Outside"))) - assertEquals("sm_01_01", resolveAlphaSignals(listOf("sm_01_01"), emptyList(), emptyList(), emptyList(), emptyList(), listOf(renamed)).single().id) - } - - @Test fun canSortsNumericValuesNumericallyAndUnknownValuesLast() { - val signals = listOf(signal("B", 2), signal("A", 1), signal("C", 3)) - assertEquals(listOf(1, 2, 3), sortCanSignals(signals, "name", false, emptyMap()).map { it.idx }) - val values = mapOf("can_1" to 10.0, "can_2" to 2.0) - assertEquals(listOf(2, 1, 3), sortCanSignals(signals, "value", false, values).map { it.idx }) - assertEquals(listOf(1, 2, 3), sortCanSignals(signals, "value", true, values).map { it.idx }) - assertEquals(signals, sortCanSignals(signals, "", false, values)) - } - - @Test fun windowStateAndLegendNoneRoundTripWithoutAffectingOtherWindows() { - val prefs = mapOf("search" to "온도", "expanded" to "true", "width:name" to "247.5", - "checked_addresses" to "1,4294967295", "sort_column" to "msgId", "sort_descending" to "true") - val windows = listOf(DpcWindowState("one", "CAN_SIGNAL_LIST", "CAN", contentState = prefs, chart = DpcChartState(legendPosition = 3)), - DpcWindowState("two", "CAN_SIGNAL_LIST", "CAN2")) - val workspace = DpcWorkspaceState("p", listOf(DpcPageState("p", "Page", windows))) - val restored = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!! - assertEquals(prefs, restored.pages.single().windows[0].contentState) - assertTrue(restored.pages.single().windows[1].contentState.isEmpty()) - assertEquals(3, restored.pages.single().windows[0].chart!!.legendPosition) - } - - @Test fun windowStoreCommitsImmediatelyAndClampsColumnWidth() { - var saved = emptyMap() - val store = WindowUiStore(emptyMap()) { saved = it } - val widths = WindowColumnWidths(store) - widths["name"] = androidx.compose.ui.unit.Dp(200f) - assertEquals("200.0", saved["width:name"]) - widths["name"] = androidx.compose.ui.unit.Dp(-2f) - assertEquals(32f, widths["name"]!!.value) - widths.remove("name") - assertFalse(saved.containsKey("width:name")) - } - - @Test fun descriptionsRoundTripInProjectExtension() { - val project = DpcProject(parameterDescriptions = mapOf("constant:K" to "설명", "array:A" to "")) - val restored = DpcProjectManager.parseProjectFromJson(DpcProjectManager.serializeProjectToJson(project), null) - assertEquals(project.parameterDescriptions, restored.parameterDescriptions) - } - - @Test fun stoppedGraphCursorUpdatesSnapshotImmediately() { - val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) - renderer.stop() - renderer.setCrosshairTimeSec(1.5) - assertEquals(1.5, renderer.renderSnapshotFlow.value.crosshairTimeSec) - renderer.setCrosshairTimeSec(null) - assertNull(renderer.renderSnapshotFlow.value.crosshairTimeSec) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/WindowRevealTest.kt b/Android/app/src/test/java/com/dct/app/tests/WindowRevealTest.kt deleted file mode 100644 index 3c7c27f..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/WindowRevealTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.presentation.mdi.windowRevealScroll -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class WindowRevealTest { - @Test fun visibleWindowDoesNotMoveViewport() { - assertEquals(100f, windowRevealScroll(100f, 800f, 150f, 400f)) - } - @Test fun revealsBothEdgesWithMinimalMovement() { - assertEquals(516f, windowRevealScroll(0f, 800f, 900f, 400f)) - assertEquals(84f, windowRevealScroll(500f, 800f, 100f, 400f)) - } - @Test fun oversizedWindowShowsLeadingEdgeAndNeverScrollsNegative() { - assertEquals(884f, windowRevealScroll(0f, 800f, 900f, 1000f)) - assertEquals(0f, windowRevealScroll(500f, 800f, 0f, 1000f)) - assertEquals(100f, windowRevealScroll(100f, 0f, 900f, 400f)) - } -} diff --git a/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt b/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt deleted file mode 100644 index 2c6063b..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.dct.app.tests - -import com.dct.app.core.engine.XcpDaqStreamDecoder -import com.dct.app.data.parser.A2lParser -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 org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.ByteArrayOutputStream - -class XcpEngineTest { - - @Test - fun testA2lDataTypeUnpacking() { - // 1. UBYTE (1B Unsigned) - val ubyteBytes = byteArrayOf(0xFE.toByte()) - assertEquals(254.0, A2lDataType.UBYTE.unpackRawValue(ubyteBytes, 0, false), 0.001) - - // 2. SBYTE (1B Signed) - val sbyteBytes = byteArrayOf(0xFE.toByte()) // -2 in 2's complement - assertEquals(-2.0, A2lDataType.SBYTE.unpackRawValue(sbyteBytes, 0, false), 0.001) - - // 3. UWORD (2B Unsigned Little-Endian vs Big-Endian) - val uwordBytes = byteArrayOf(0x34, 0x12) // 0x1234 = 4660 - assertEquals(4660.0, A2lDataType.UWORD.unpackRawValue(uwordBytes, 0, isMsbFirst = false), 0.001) - assertEquals(0x3412.toDouble(), A2lDataType.UWORD.unpackRawValue(uwordBytes, 0, isMsbFirst = true), 0.001) - - // 4. SWORD (2B Signed Little-Endian) - val swordBytes = byteArrayOf(0xFE.toByte(), 0xFF.toByte()) // -2 in 16-bit - assertEquals(-2.0, A2lDataType.SWORD.unpackRawValue(swordBytes, 0, isMsbFirst = false), 0.001) - - // 5. ULONG (4B Unsigned Little-Endian) - val ulongBytes = byteArrayOf(0x78, 0x56, 0x34, 0x12) // 0x12345678 = 305419896 - assertEquals(305419896.0, A2lDataType.ULONG.unpackRawValue(ulongBytes, 0, isMsbFirst = false), 0.001) - - // 6. FLOAT32_IEEE (4B Float Little-Endian) - val floatStream = ByteArrayOutputStream() - val floatBits = java.lang.Float.floatToIntBits(123.456f) - floatStream.write(floatBits and 0xFF) - floatStream.write((floatBits shr 8) and 0xFF) - floatStream.write((floatBits shr 16) and 0xFF) - floatStream.write((floatBits shr 24) and 0xFF) - val floatBytes = floatStream.toByteArray() - assertEquals(123.456, A2lDataType.FLOAT32_IEEE.unpackRawValue(floatBytes, 0, isMsbFirst = false), 0.001) - } - - @Test - fun testXcpDaqStreamDecoder() { - val decoder = XcpDaqStreamDecoder() - - val speedSig = SignalDefinition( - reclid = 0, - idx = 0, - name = "Engine_Speed", - attr = MdbAttrType.ATTR_2_UINT16, - cvsA = 0.25, - cvsB = 0.0, - unit = "rpm", - color = 0xFF0000, - min = 0.0, - max = 8000.0, - adl = 0x1000 - ) - - val throttleSig = SignalDefinition( - reclid = 0, - idx = 1, - name = "Throttle_Pos", - attr = MdbAttrType.ATTR_0_UINT8, - cvsA = 0.5, - cvsB = 0.0, - unit = "%", - color = 0x00FF00, - min = 0.0, - max = 100.0, - adl = 0x1002 - ) - - val rawMeasurements = listOf( - A2lParser.A2lMeasurement( - name = "Engine_Speed", - dataType = "UWORD", - a2lDataType = A2lDataType.UWORD, - conversionMethod = "SpeedMethod", - ecuAddress = 0x1000L - ), - A2lParser.A2lMeasurement( - name = "Throttle_Pos", - dataType = "UBYTE", - a2lDataType = A2lDataType.UBYTE, - conversionMethod = "ThrottleMethod", - ecuAddress = 0x1002L - ) - ) - - val compuMethods = mapOf( - "speedmethod" to A2lParser.CompuMethod("SpeedMethod", conversionType = "LINEAR", cvsA = 0.25, cvsB = 0.0), - "throttlemethod" to A2lParser.CompuMethod("ThrottleMethod", conversionType = "LINEAR", cvsA = 0.5, cvsB = 0.0) - ) - - decoder.configureDaqSignals( - signals = listOf(speedSig, throttleSig), - rawMeasurements = rawMeasurements, - compuMethods = compuMethods, - isMsbFirst = false - ) - - assertTrue(decoder.isConfigured()) - - // PC D0 payload: count(2B BE) + [type + value] 반복 - val payload = ByteArrayOutputStream().apply { - write(0x00) - write(0x02) - // Speed = 4000 = 0x0FA0 (Little-Endian: 0xA0, 0x0F) - write(A2lDataType.UWORD.code) - write(0xA0) - write(0x0F) - // Throttle = 150 = 0x96 - write(A2lDataType.UBYTE.code) - write(0x96) - }.toByteArray() - - val result = decoder.decodeDaqPayloadResult(payload, captureRawValues = true) as XcpDaqStreamDecoder.DecodeResult.Success - assertEquals(4000.0, result.rawValues["Engine_Speed"]!!.toDouble(), 0.0) - assertEquals(150.0, result.rawValues["Throttle_Pos"]!!.toDouble(), 0.0) - val decoded = result.values - assertEquals(1000.0, decoded["Engine_Speed"] ?: 0.0, 0.001) - assertEquals(75.0, decoded["Throttle_Pos"] ?: 0.0, 0.001) - assertEquals(1000.0, decoded["a2l_4096"] ?: 0.0, 0.001) // 0x1000 = 4096 - - assertTrue(decoder.decodeDaqPayload(byteArrayOf(0x00, 0x02, 0x02)).isEmpty()) - val wrongType = byteArrayOf(0x00, 0x02, 0x00, 0x01, 0x00, 0x02) - assertTrue(decoder.decodeDaqPayload(wrongType).isEmpty()) - } - - @Test - fun rawUInt64BitsSurviveBothWireByteOrdersWithoutPhysicalDoubleRounding() { - for (msb in listOf(false, true)) { - val decoder = XcpDaqStreamDecoder() - val signal = SignalDefinition(0, 0, "Counter", MdbAttrType.ATTR_4_UINT32, - 1.0, 0.0, "", 0L, 0.0, 0.0, a2lDataType = A2lDataType.UINT64) - decoder.configureDaqSignals(listOf(signal), isMsbFirst = msb) - val bits = 0xFEDCBA9876543210uL.toLong() - val data = java.nio.ByteBuffer.allocate(8).order(if (msb) - java.nio.ByteOrder.BIG_ENDIAN else java.nio.ByteOrder.LITTLE_ENDIAN).putLong(bits).array() - val payload = byteArrayOf(0, 1, A2lDataType.UINT64.code.toByte()) + data - val result = decoder.decodeDaqPayloadResult(payload, captureRawValues = true) as XcpDaqStreamDecoder.DecodeResult.Success - assertEquals(bits, result.rawValues["Counter"]) - assertTrue((decoder.decodeDaqPayloadResult(payload) as XcpDaqStreamDecoder.DecodeResult.Success).rawValues.isEmpty()) - decoder.clear() - assertTrue(decoder.recordingMetadata().isEmpty()) - } - } - -} diff --git a/Android/app/src/test/java/com/dct/app/tests/YAxisHitTest.kt b/Android/app/src/test/java/com/dct/app/tests/YAxisHitTest.kt deleted file mode 100644 index 7eaae92..0000000 --- a/Android/app/src/test/java/com/dct/app/tests/YAxisHitTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.dct.app.tests - -import androidx.compose.ui.geometry.Offset -import com.dct.app.domain.model.chart.* -import com.dct.app.presentation.chart.hitTestYAxis -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class YAxisHitTest { - private val bounds = PlotBounds(180f, 20f, 500f, 300f) - private val left = listOf(ChartAxisConfig(AxisPosition.LEFT_0), ChartAxisConfig(AxisPosition.LEFT_1)) - private val right = listOf(ChartAxisConfig(AxisPosition.RIGHT_0), ChartAxisConfig(AxisPosition.RIGHT_1)) - private fun hit(x: Float, y: Float = 100f) = hitTestYAxis(Offset(x, y), left, right, bounds) - - @Test fun leftNumbersBelongToTheirAxisNotTheNearestLine() { - assertEquals(AxisPosition.LEFT_1, hit(120f)) // 60px left of inner axis - assertEquals(AxisPosition.LEFT_0, hit(48f)) - assertEquals(AxisPosition.LEFT_0, hit(108f)) // exact shared boundary - assertEquals(AxisPosition.LEFT_1, hit(180f)) - } - @Test fun rightNumbersAndPinchEndpointsUseTheSameLane() { - assertEquals(AxisPosition.RIGHT_0, hit(505f)) - assertEquals(AxisPosition.RIGHT_0, hit(560f)) - assertEquals(AxisPosition.RIGHT_1, hit(572f)) - assertEquals(AxisPosition.RIGHT_1, hit(632f)) - } - @Test fun plotAndOutsideAxisHeightRemainUnclaimed() { - assertNull(hit(300f)) - assertNull(hit(120f, 19f)) - assertNull(hit(120f, 301f)) - assertNull(hit(20f)) - assertNull(hit(650f)) - } -} diff --git a/Android/launch_test.ps1 b/Android/launch_test.ps1 deleted file mode 100644 index 131220d..0000000 --- a/Android/launch_test.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -$env:JAVA_HOME = "C:\Program Files\Android\openjdk\jdk-21.0.8" -$env:Path = "$env:JAVA_HOME\bin;" + $env:Path -Set-Location "C:\Users\COMPUTER1\Desktop\mobi\DCT\Android" - -Remove-Item -Path "C:\Users\COMPUTER1\Desktop\mobi\DCT\test_done.txt" -Force -ErrorAction SilentlyContinue -Remove-Item -Path "C:\Users\COMPUTER1\Desktop\mobi\DCT\test_result.log" -Force -ErrorAction SilentlyContinue - -$proc = Start-Process -FilePath "cmd.exe" -ArgumentList "/c call gradlew.bat testDebugUnitTest > C:\Users\COMPUTER1\Desktop\mobi\DCT\test_result.log 2>&1 & echo DONE! > C:\Users\COMPUTER1\Desktop\mobi\DCT\test_done.txt" -WorkingDirectory "C:\Users\COMPUTER1\Desktop\mobi\DCT\Android" -WindowStyle Hidden -PassThru - -Write-Output "STARTED PID $($proc.Id)" diff --git a/Android/run_tests.ps1 b/Android/run_tests.ps1 deleted file mode 100644 index b9b43f3..0000000 --- a/Android/run_tests.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$env:JAVA_HOME = "C:\Program Files\Android\openjdk\jdk-21.0.8" -$env:Path = "$env:JAVA_HOME\bin;" + $env:Path -Set-Location "C:\Users\COMPUTER1\Desktop\mobi\DCT\Android" -Stop-Process -Name java -Force -ErrorAction SilentlyContinue -Start-Sleep -Seconds 1 -Remove-Item -Recurse -Force "app\build\test-results" -ErrorAction SilentlyContinue -cmd.exe /c "gradlew.bat --no-daemon testDebugUnitTest > test_output.txt 2>&1" -Get-Content "test_output.txt" -Tail 50 diff --git a/Android/scripts/PcMdf4Reference.cs b/Android/scripts/PcMdf4Reference.cs deleted file mode 100644 index 3dbce1e..0000000 --- a/Android/scripts/PcMdf4Reference.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Read-only reference harness for the PC's bundled Windows x86 MDF4Writer.dll. -// Mirrors WriteA2LMDF4.cpp's DLL branch and accepts common raw input JSON. -using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Runtime.InteropServices; -using System.Web.Script.Serialization; - -class PcMdf4Reference { - const string Dll = "MDF4Writer.dll"; - [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern bool SetDllDirectory(string path); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern IntPtr M4WRInitDll(); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern void M4WRExitDll(IntPtr h); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern void M4WRSetPathName(IntPtr h, string path); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRCreateMDF4(IntPtr h, string tool, string vendor, string version, string comment, IntPtr mdfVersion); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRFileDescriptionEx(IntPtr h, string comment, int quality, double start, ushort tz, ushort dst, byte flags); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern void M4WRAddGroup64(IntPtr h, string name, long count, out IntPtr group); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRAddTimeSignal(IntPtr h, int group, string name, int type, int first, int bits, string unit, double factor, double offset, out int signal); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRAddSignal(IntPtr h, int group, string name, string label, int type, int first, int bits, string unit, double factor, double offset, int hasNoValue, double noValue, int invalidBit, out int signal); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRSignalDescription(IntPtr h, int group, int signal, string name, string path, string comment); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRMakeGroups(IntPtr h); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRSetSignalValue(IntPtr h, int group, int signal, double value, int noValue); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRWriteRecord(IntPtr h, int group); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRSetMinMax(IntPtr h, int group, int signal, double min, double max); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRFlushGroup(IntPtr h, int group); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern void M4WRClose(IntPtr h); - [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern void M4WRSetNanoTimeUTC(IntPtr h, long time); - static double Number(object o) { return Convert.ToDouble(o, CultureInfo.InvariantCulture); } - static int Main(string[] args) { - if (args.Length != 3) throw new ArgumentException("dll-directory input-json new-output-mf4"); - if (File.Exists(args[2])) throw new IOException("Refusing to overwrite existing reference output"); - SetDllDirectory(Path.GetFullPath(args[0])); - var json = new JavaScriptSerializer { MaxJsonLength = Int32.MaxValue }; - var data = json.Deserialize>(File.ReadAllText(args[1])); - var channels = (ArrayList)data["channels"]; - var ids = new int[channels.Count]; - IntPtr h = M4WRInitDll(); - try { - M4WRSetPathName(h, Path.GetFullPath(args[2])); - if (!M4WRCreateMDF4(h, "DCT", "Doowon Climate Control", "1.0", "", new IntPtr(410))) throw new Exception("CreateMDF4 failed"); - M4WRSetNanoTimeUTC(h, Convert.ToInt64(data["start_ns"])); - if (!M4WRFileDescriptionEx(h, "", -1, Number(data["start_ns"])/1000000000.0, - Convert.ToUInt16(data["timezone_minutes"]), Convert.ToUInt16(data["dst_minutes"]), Convert.ToByte(data["time_flags"]))) throw new Exception("FileDescription failed"); - IntPtr group; - M4WRAddGroup64(h, "Measurements", 10000, out group); - int g = group.ToInt32(); - if (!M4WRAddTimeSignal(h, g, "Time", 4, 0, 64, "s", 1, 0, out ids[0])) throw new Exception("Time failed"); - for (int i=1; i)channels[i]; - double factor=1, offset=0; - if (c["conversion"] != null) { - var cc=(Dictionary)c["conversion"]; - if (Convert.ToInt32(cc["type"])!=1) throw new Exception("DLL AddSignal reference only supports linear conversion"); - var p=(ArrayList)cc["parameters"]; offset=Number(p[0]); factor=Number(p[1]); - } - if (!M4WRAddSignal(h,g,(string)c["name"],(string)c["description"],Convert.ToInt32(c["data_type"]),-1,Convert.ToInt32(c["bit_count"]),(string)c["unit"],factor,offset,0,0,-1,out ids[i])) throw new Exception("Signal failed: "+c["name"]); - if ((Convert.ToInt32(c["flags"]) & 8) != 0) - M4WRSetMinMax(h,g,ids[i],Number(c["min_raw"]),Number(c["max_raw"])); - if (c["source"]!=null) { - var s=(Dictionary)c["source"]; - M4WRSignalDescription(h,g,ids[i],(string)s["name"],(string)s["path"],(string)s["comment"]); - } - } - if (!M4WRMakeGroups(h)) throw new Exception("MakeGroups failed"); - // PC WriteData reads every EnvSetting item's current value on every record. - // That state survives unreceived messages; the DLL's record buffer itself resets. - var currentValues = new double[ids.Length]; - foreach(ArrayList record in (ArrayList)data["records"]) { - for(int i=0; i 0 - for a, b in zip(left.file_history, right.file_history): - x, y = ET.fromstring(a.comment), ET.fromstring(b.comment) - for key in ('TX', 'tool_id', 'tool_vendor', 'tool_version'): - assert x.findtext(key) == y.findtext(key), ('File History', key) - original = x.find("./common_properties/e[@name='Free_MDF4_Lib']") - if original is not None: - same_writer = y.find("./common_properties/e[@name='Free_MDF4_Lib']") - if same_writer is not None: - assert original.text == same_writer.text - else: - # Android must identify its actual writer, not impersonate the Windows DLL. - assert y.findtext("./common_properties/e[@name='writer']") == 'DCT Android; MDF4Writer.dll-compatible' - assert y.findtext("./common_properties/e[@name='reference_writer']") == 'Free_MDF4_Lib ' + original.text - for group_index, (a, b) in enumerate(zip(left.groups, right.groups)): - for key in ('comment', 'record_id_len'): - assert getattr(a.data_group, key) == getattr(b.data_group, key), ('DG', key) - for key in ('acq_name', 'comment', 'flags', 'record_id', 'invalidation_bytes_nr'): - assert getattr(a.channel_group, key) == getattr(b.channel_group, key), ('CG', key) - assert a.channel_group.samples_byte_nr == b.channel_group.samples_byte_nr - assert a.channel_group.cycles_nr == b.channel_group.cycles_nr - assert len(a.channels) == len(b.channels) - for index, (x, y) in enumerate(zip(a.channels, b.channels)): - cx, cy = channel(x), channel(y) - for key in ('name', 'unit', 'description', 'data_type', 'bit_count', 'bit_offset', - 'byte_offset', 'conversion', 'source', 'flags', 'min_raw', 'max_raw'): - assert cx[key] == cy[key], (index, x.name, key, cx[key], cy[key]) - np.testing.assert_array_equal(left.get(group=group_index, index=index, raw=True).samples, - right.get(group=group_index, index=index, raw=True).samples) - np.testing.assert_allclose(left.get(group=group_index, index=index).samples, - right.get(group=group_index, index=index).samples, rtol=0, atol=1e-12) - np.testing.assert_array_equal(left.get_master(group_index), right.get_master(group_index)) - print('PASS: version, HD/FH metadata (explicit actual writer exception), epoch/timezone, DG/CG metadata, channel order/types/bits/offsets, record size, count, raw/physical values, Time, units/descriptions/SI/conversion') - print('HEADER COMMENTS', left.header.comment == right.header.comment) - print('HISTORY LEFT', [h.comment for h in left.file_history]) - print('HISTORY RIGHT', [h.comment for h in right.file_history]) - reference_blocks, candidate_blocks = blocks(reference), blocks(candidate) - for kind in ('##HD', '##DG', '##CG', '##CN', '##SI', '##CC', '##FH', '##MD', '##HL'): - assert reference_blocks['counts'].get(kind, 0) == candidate_blocks['counts'].get(kind, 0), kind - sizes = {g.channel_group.samples_byte_nr for g in right.groups} - for info in (reference_blocks, candidate_blocks): - assert info['counts'].get('##DT', 0) == 0, 'PC profile requires compressed data blocks' - for dz in info['compression']: - assert dz['original'] == 'DT' and dz['zip_type'] == 1 - assert dz['parameter'] in sizes and dz['raw_size'] % dz['parameter'] == 0 - assert sum(d['raw_size'] for d in reference_blocks['compression']) == sum( - d['raw_size'] for d in candidate_blocks['compression']) - print('PASS: semantic block counts and transposed zlib compression; TX deduplication, DL chunking, compressed bytes and actual writer may differ') - print('REFERENCE BLOCKS', json.dumps(reference_blocks)) - print('CANDIDATE BLOCKS', json.dumps(candidate_blocks)) - - -def verify_fixtures(root): - # These files are generated by the Android implementation, but decoded only by asammdf. - for name, count, width in [('chunks.mf4', 10003, 10), ('wide-pc.mf4', 400, 19764), - ('empty-pc.mf4', 0, 10), ('queued-pc.mf4', 1, 10)]: - with MDF(root / name) as m: - g = m.groups[0] - assert g.channel_group.cycles_nr == count - assert g.channel_group.samples_byte_nr == width - expected_time = [-.01] if name == 'queued-pc.mf4' else np.arange(count) / 1000 - np.testing.assert_array_equal(m.get_master(0), expected_time) - if count: - expected_values = [-2] if name == 'queued-pc.mf4' else np.arange(count) - np.testing.assert_array_equal(m.get(group=0, index=1, raw=True).samples, expected_values) - if name == 'wide-pc.mf4': - assert len(g.channels) == 4940 - np.testing.assert_array_equal(m.get(group=0, index=4939, raw=True).samples, np.zeros(count)) - print('PASS independent reader:', name, 'samples', count, 'record bytes', width) - with MDF(root / 'conversions-pc.mf4') as m: - np.testing.assert_array_equal(m.get('Rational', raw=True).samples, [2, 5]) - np.testing.assert_array_equal(m.get('Table', raw=True).samples, [2, 5]) - np.testing.assert_allclose(m.get('Rational').samples, [11, 38], rtol=0, atol=1e-12) - np.testing.assert_allclose(m.get('Table').samples, [20, 50], rtol=0, atol=1e-12) - assert m.groups[0].channels[1].source.path == 'ECU.Rational' - assert m.groups[0].channels[1].unit == 'degC' - print('PASS independent reader: A2L factory, rational and interpolated CC, raw/physical values, original unit/SI') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('action', choices=['export', 'compare', 'fixtures']) - parser.add_argument('source', type=Path) - parser.add_argument('target', type=Path, nargs='?') - args = parser.parse_args() - if args.action == 'fixtures': - verify_fixtures(args.source) - else: - if args.target is None: - parser.error('export/compare requires target') - (export if args.action == 'export' else compare)(args.source, args.target) diff --git a/Android/scripts/verify-local.ps1 b/Android/scripts/verify-local.ps1 deleted file mode 100644 index 26498f7..0000000 --- a/Android/scripts/verify-local.ps1 +++ /dev/null @@ -1,51 +0,0 @@ -param([string[]]$TestClasses = @(), [switch]$SkipCompile) -$ErrorActionPreference = 'Stop' -$taskRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$taskBuild = Join-Path $taskRoot 'app/build/workspace-verification' -$taskJava = 'C:/Program Files/Android/Android Studio/jbr/bin/java.exe' -$taskCompiler = 'C:/Program Files/Android/Android Studio/plugins/Kotlin/kotlinc/lib/*' -$taskJars = @(Get-ChildItem 'C:/Users/COMPUTER1/.gradle/caches' -Filter '*.jar' -Recurse | ForEach-Object { $_.FullName.Replace('\', '/') }) -$taskHamcrest = Join-Path $taskBuild 'hamcrest-core-1.3.jar' -function Resolve-Classpath($lines) { - @($lines | ForEach-Object { - if ($_ -match '\.jar;') { - $fixed = @($_.Trim('"').Split(';') | ForEach-Object { - $path = $_ - if (Test-Path -LiteralPath $path) { $path } - else { - $suffix = if ($path -match '/transformed/(.+)$') { $Matches[1] } else { [System.IO.Path]::GetFileName($path) } - $replacement = $taskJars | Where-Object { $_.EndsWith('/' + $suffix) } | Select-Object -First 1 - if (!$replacement -and $path -match '/files-2.1/([^/]+)/([^/]+)/') { - $modulePath = '/files-2.1/' + $Matches[1] + '/' + $Matches[2] + '/' - $replacement = $taskJars | Where-Object { $_.Contains($modulePath) } | Sort-Object -Descending | Select-Object -First 1 - } - if ($replacement) { $replacement } - else { Write-Warning "Obsolete cached dependency omitted: $path" } - } - }) - if (Test-Path -LiteralPath $taskHamcrest) { $fixed += $taskHamcrest.Replace('\', '/') } - '"' + (($fixed | Select-Object -Unique) -join ';') + '"' - } else { $_ } - }) -} -function Compile-Sources($template, $name, $sourceRoot) { - $argsLines = @(Resolve-Classpath @(Get-Content (Join-Path $taskBuild $template) | Where-Object { $_ -notmatch '^".*\.(kt|java)"$' })) - $sources = @(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.kt' -Recurse | ForEach-Object { '"' + $_.FullName.Replace('\', '/') + '"' }) - if ($name -eq 'test-current.args') { $sources += '"' + (Join-Path $taskBuild 'RunWorkspaceTests.kt').Replace('\', '/') + '"' } - $file = Join-Path $taskBuild $name - [System.IO.File]::WriteAllLines($file, ($argsLines + $sources)) - & $taskJava '-cp' $taskCompiler 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler' ('@' + $file) 2>&1 | Tee-Object (Join-Path $taskBuild ($name + '.log')) | Select-Object -Last 55 - if ($LASTEXITCODE -ne 0) { throw "Compilation failed: $name" } -} -if (!$SkipCompile) { - Compile-Sources 'compile-main.args' 'main-current.args' (Join-Path $taskRoot 'app/src/main/java') - Compile-Sources 'test.args' 'test-current.args' (Join-Path $taskRoot 'app/src/test/java') -} -$runLines = @(Resolve-Classpath @(Get-Content (Join-Path $taskBuild 'run-tests.args'))) -$runnerLine = [Array]::IndexOf($runLines, '"RunWorkspaceTestsKt"') -if ($runnerLine -lt 0) { throw 'JUnit launcher argument not found' } -$selected = @($TestClasses | ForEach-Object { '"' + $_ + '"' }) -$runFile = Join-Path $taskBuild 'run-current.args' -[System.IO.File]::WriteAllLines($runFile, @($runLines[0..$runnerLine]) + $selected) -Push-Location $taskRoot -try { & $taskJava '-Dfile.encoding=UTF-8' ('@' + $runFile) 2>&1 | Tee-Object (Join-Path $taskBuild 'test-current.log') | Select-Object -Last 65; exit $LASTEXITCODE } finally { Pop-Location } diff --git a/Android/scripts/verify-ui-local.ps1 b/Android/scripts/verify-ui-local.ps1 deleted file mode 100644 index 5784cfd..0000000 --- a/Android/scripts/verify-ui-local.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -$ErrorActionPreference = 'Stop' -$taskRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$taskBuild = Join-Path $taskRoot 'app/build/workspace-verification' -$taskJava = 'C:/Program Files/Android/Android Studio/jbr/bin/java.exe' -$taskKotlin = 'C:/Program Files/Android/Android Studio/plugins/Kotlin/kotlinc/lib' -$taskArgs = @(Get-Content (Join-Path $taskBuild 'test-current.args') | Where-Object { $_ -notmatch '^".*\.(kt|java)"$' } | ForEach-Object { $_.Trim('"') }) -$taskJars = @(Get-ChildItem 'C:/Users/COMPUTER1/.gradle/caches' -Filter 'classes.jar' -Recurse | Where-Object { - $_.FullName -match 'transformed[\\/](ui-test-release|ui-test-junit4-release|monitor-1.6.1|espresso-core-3.5.1|runner-1.5.2)[\\/]' -} | Group-Object { $_.Directory.Parent.Name } | ForEach-Object { $_.Group[0].FullName.Replace('\', '/') }) -$taskClasspathIndex = [Array]::IndexOf($taskArgs, '-classpath') + 1 -if ($taskClasspathIndex -le 0 -or $taskJars.Count -lt 5) { throw 'UI test compiler dependencies unavailable' } -$taskArgs[$taskClasspathIndex] += ';' + ($taskJars -join ';') -$taskOutputIndex = [Array]::IndexOf($taskArgs, '-d') + 1 -if ($taskOutputIndex -le 0) { throw 'Missing Kotlin output argument' } -$taskArgs[$taskOutputIndex] = (Join-Path $taskBuild 'androidTest-ui').Replace('\', '/') -$taskArgs += '-Xplugin=' + $taskKotlin + '/compose-compiler-plugin.jar' -$taskArgs += @(Get-ChildItem (Join-Path $taskRoot 'app/src/androidTest/java') -Filter '*.kt' -Recurse | ForEach-Object { $_.FullName.Replace('\', '/') }) -$taskArgsFile = Join-Path $taskBuild 'ui-current.args' -[System.IO.File]::WriteAllLines($taskArgsFile, @($taskArgs | ForEach-Object { '"' + $_ + '"' })) -& $taskJava '-cp' ($taskKotlin + '/*') 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler' ('@' + $taskArgsFile) 2>&1 | Tee-Object (Join-Path $taskBuild 'ui-current.log') -exit $LASTEXITCODE diff --git a/Android/scripts/verify_mf4_fixtures.py b/Android/scripts/verify_mf4_fixtures.py deleted file mode 100644 index 8246b8d..0000000 --- a/Android/scripts/verify_mf4_fixtures.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Independent MDF 4.10 check of artifacts exported by Mf4RecordingRegressionTest. - -Set DCT_RECORDING_FIXTURES_DIR when running the JUnit test, then run this script -with that directory. Requires asammdf; it is not an Android/build dependency. -""" -import argparse -import csv -from datetime import datetime -from pathlib import Path - -import numpy as np -from asammdf import MDF - - -def verify(directory): - checks = [] - with MDF(directory / 'relative.mf4') as recording: - assert recording.version == '4.10' - assert len(recording.groups) == 1 - assert recording.groups[0].channel_group.cycles_nr == 2 - temperature = recording.get('온도') - assert temperature.unit == '°C' - np.testing.assert_array_equal(temperature.samples, [-12.5, 3.75]) - np.testing.assert_allclose(temperature.timestamps, [0.125, 0.5], atol=1e-6) - np.testing.assert_array_equal(recording.get('Speed').samples, [87.25, 88.0]) - assert np.isnan(recording.get('Missing').samples).all() - checks.append('relative time, names, units, negative/fractional values, NaN') - for name, channels in [('empty.mf4', 2), ('time_only.mf4', 1)]: - with MDF(directory / name) as recording: - group = recording.groups[0] - assert group.channel_group.cycles_nr == 0 - assert len(group.channels) == channels - assert len(recording.get_master(0)) == 0 - checks.append(name) - with MDF(directory / 'wide.mf4') as recording: - assert len(recording.groups[0].channels) == 4940 - assert recording.groups[0].channel_group.cycles_nr == 1 - assert recording.get('DBC_0').samples.tolist() == [123.5] - assert np.isnan(recording.get('DBC_4938').samples).all() - np.testing.assert_allclose(recording.get_master(0), [0.25], atol=1e-6) - checks.append('4939-signal DBC width and missing tail') - with MDF(directory / 'restart.mf4') as recording: - assert recording.get('Signal').samples.tolist() == [2.0] - np.testing.assert_allclose(recording.get_master(0), [0.25], atol=1e-6) - checks.append('restart resets origin, rejects nonfinite timestamps') - with (directory / 'paired.csv').open(encoding='utf-8', newline='') as stream: - rows = list(csv.reader(stream))[7:] - with MDF(directory / 'paired.mf4') as recording: - signal = recording.get('Signal') - assert len(rows) == len(signal.samples) == 2 - assert [int(row[1]) for row in rows] == [0, 1] - np.testing.assert_array_equal([float(row[2]) for row in rows], signal.samples) - assert [row[0][-4:] for row in rows] == ['.125', '.500'] - np.testing.assert_allclose(signal.timestamps, [0.125, 0.5], atol=1e-6) - checks.append('CSV/MF4 rows, values and ingress timestamps match') - shared_origins = [] - for source, first_offset in [('mdb', -0.010), ('dbc', -0.009), ('formula', -0.010)]: - with (directory / f'queued_{source}.csv').open(encoding='utf-8', newline='') as stream: - rows = list(csv.reader(stream))[7:] - with MDF(directory / f'queued_{source}.mf4') as recording: - signal = recording.get('Signal') - origin = recording.start_time.timestamp() - shared_origins.append(origin) - assert len(rows) == len(signal.samples) == 3 - assert [int(row[1]) for row in rows] == [0, 1, 2] - np.testing.assert_array_equal(signal.samples, [0., 1., 2.]) - np.testing.assert_array_equal([float(row[2]) for row in rows], signal.samples) - np.testing.assert_allclose(signal.timestamps, [first_offset, 0., 0.125], rtol=0, atol=1e-6) - np.testing.assert_allclose(origin, 1790923490.915, rtol=0, atol=1e-6) - for row, offset in zip(rows, signal.timestamps): - # UTC/local HH:mm:ss differences do not change the offset within a minute. - csv_second = float(row[0].rsplit(':', 1)[1]) - mf4_second = (origin + offset) % 60 - np.testing.assert_allclose(csv_second, mf4_second, rtol=0, atol=0.001) - checks.append(f'{source}: queued ingress preserved, CSV/MF4 absolute time and values match') - assert len(set(shared_origins)) == 1 - with MDF(directory / 'queued_midnight.mf4') as recording: - signal = recording.get('Signal') - origin = recording.start_time.timestamp() - np.testing.assert_allclose(signal.timestamps, [-0.010], rtol=0, atol=1e-6) - np.testing.assert_array_equal(signal.samples, [7.]) - absolute = origin + signal.timestamps[0] - np.testing.assert_allclose(absolute, 1790985599.995, rtol=0, atol=1e-6) - assert datetime.fromtimestamp(absolute, recording.start_time.tzinfo).date() < recording.start_time.date() - checks.append('queued ingress crossing UTC midnight preserves the previous date') - for check in checks: - print('PASS:', check) - print('PASS: ten MF4 files opened by independent reader') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('directory', type=Path) - verify(parser.parse_args().directory)