Browse Source

Configure release signing and exclude app test sources

master
seuuung 4 days ago
parent
commit
f7db957147
  1. 12
      .gitignore
  2. 31
      Android/app/build.gradle.kts
  3. 210
      Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt
  4. 94
      Android/app/src/androidTest/java/com/dct/app/background/ProgramExitSessionTest.kt
  5. 114
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt
  6. 416
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt
  7. 69
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt
  8. 94
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt
  9. 51
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt
  10. 67
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ChartNavigationUiTest.kt
  11. 167
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/CompensationLayoutUiTest.kt
  12. 115
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ConstantWindowUiTest.kt
  13. 26
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DebugValueResetUiTest.kt
  14. 32
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialogUiTest.kt
  15. 42
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt
  16. 258
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt
  17. 116
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt
  18. 80
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt
  19. 181
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt
  20. 62
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt
  21. 182
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenuUiTest.kt
  22. 102
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiConnectionRibbonUiTest.kt
  23. 76
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiManualConnectionUiTest.kt
  24. 144
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowActivationTest.kt
  25. 76
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowParityUiTest.kt
  26. 534
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt
  27. 85
      Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt
  28. 163
      Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt
  29. 90
      Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt
  30. 291
      Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt
  31. 81
      Android/app/src/test/java/com/dct/app/data/protocol/DctOtaProtocolTest.kt
  32. 292
      Android/app/src/test/java/com/dct/app/presentation/maintenance/MaintenanceUiParityTest.kt
  33. 54
      Android/app/src/test/java/com/dct/app/presentation/monitor/components/RibbonSubmenuPositionTest.kt
  34. 32
      Android/app/src/test/java/com/dct/app/presentation/signaltree/SignalTableTextStylesTest.kt
  35. 78
      Android/app/src/test/java/com/dct/app/presentation/signaltree/TableAutoFitTest.kt
  36. 113
      Android/app/src/test/java/com/dct/app/tests/A2lCharacteristicLayoutTest.kt
  37. 223
      Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt
  38. 106
      Android/app/src/test/java/com/dct/app/tests/AlphanumericalUpdatesTest.kt
  39. 213
      Android/app/src/test/java/com/dct/app/tests/AttrConverterTest.kt
  40. 236
      Android/app/src/test/java/com/dct/app/tests/CalibrationEditorTest.kt
  41. 108
      Android/app/src/test/java/com/dct/app/tests/CanDbcParserTest.kt
  42. 212
      Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt
  43. 44
      Android/app/src/test/java/com/dct/app/tests/CanSignalKeyTest.kt
  44. 253
      Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt
  45. 138
      Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt
  46. 49
      Android/app/src/test/java/com/dct/app/tests/ChartCursorTooltipTest.kt
  47. 90
      Android/app/src/test/java/com/dct/app/tests/ChartInitialFillTest.kt
  48. 395
      Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt
  49. 74
      Android/app/src/test/java/com/dct/app/tests/ChartPerformanceLifecycleTest.kt
  50. 167
      Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt
  51. 127
      Android/app/src/test/java/com/dct/app/tests/ChartTelemetryResolverTest.kt
  52. 248
      Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt
  53. 108
      Android/app/src/test/java/com/dct/app/tests/ChecksumCalculatorTest.kt
  54. 21
      Android/app/src/test/java/com/dct/app/tests/ColorRefCompatibilityTest.kt
  55. 128
      Android/app/src/test/java/com/dct/app/tests/CompensationLayoutTest.kt
  56. 50
      Android/app/src/test/java/com/dct/app/tests/ConstantPresentationTest.kt
  57. 218
      Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt
  58. 170
      Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt
  59. 61
      Android/app/src/test/java/com/dct/app/tests/DctVersionResponseTest.kt
  60. 54
      Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt
  61. 721
      Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt
  62. 107
      Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt
  63. 51
      Android/app/src/test/java/com/dct/app/tests/FirmwareFileParserTest.kt
  64. 96
      Android/app/src/test/java/com/dct/app/tests/FormulaEvaluatorTest.kt
  65. 87
      Android/app/src/test/java/com/dct/app/tests/FormulaIntegrationTest.kt
  66. 173
      Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryEngineTest.kt
  67. 371
      Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt
  68. 31
      Android/app/src/test/java/com/dct/app/tests/FormulaWindowStateTest.kt
  69. 168
      Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt
  70. 223
      Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt
  71. 79
      Android/app/src/test/java/com/dct/app/tests/GraphTypeTest.kt
  72. 70
      Android/app/src/test/java/com/dct/app/tests/HardwareWireRegressionTest.kt
  73. 137
      Android/app/src/test/java/com/dct/app/tests/HeartbeatIdleRegressionTest.kt
  74. 94
      Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt
  75. 89
      Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt
  76. 2018
      Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt
  77. 100
      Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt
  78. 127
      Android/app/src/test/java/com/dct/app/tests/MdbRecordDecoderTest.kt
  79. 253
      Android/app/src/test/java/com/dct/app/tests/MdbRepositoryCrudTest.kt
  80. 246
      Android/app/src/test/java/com/dct/app/tests/MdbSchemaParserTest.kt
  81. 247
      Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt
  82. 114
      Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt
  83. 89
      Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt
  84. 31
      Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt
  85. 244
      Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt
  86. 100
      Android/app/src/test/java/com/dct/app/tests/MeasurementRecordingConcurrencyTest.kt
  87. 251
      Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt
  88. 396
      Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt
  89. 168
      Android/app/src/test/java/com/dct/app/tests/Mf4RecordingRegressionTest.kt
  90. 139
      Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt
  91. 203
      Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt
  92. 123
      Android/app/src/test/java/com/dct/app/tests/PacketFramingAndCodecTest.kt
  93. 23
      Android/app/src/test/java/com/dct/app/tests/PacketPipelineMetricsTest.kt
  94. 166
      Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt
  95. 239
      Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt
  96. 659
      Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt
  97. 114
      Android/app/src/test/java/com/dct/app/tests/PcCsvTimestampTest.kt
  98. 158
      Android/app/src/test/java/com/dct/app/tests/PcDpcCompatibilityTest.kt
  99. 268
      Android/app/src/test/java/com/dct/app/tests/PcMf4CompatibilityTest.kt
  100. 189
      Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt

12
.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 빌드 산출물 및 캐시 제외
# ------------------------------------------------------------------------------

31
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

210
Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt

@ -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<DctApplication>()
@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<Socket>()
val peerError = AtomicReference<Throwable>()
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<MutableStateFlow<DeviceConnectionState>>(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<CopyOnWriteArrayList<CsvMeasurementLogger>>(session.viewModel, "activeCsvLoggers").add(logger)
field<CopyOnWriteArrayList<Mf4MeasurementLogger>>(session.viewModel, "activeMf4Loggers").add(mf4)
val ui = field<MutableStateFlow<MainUiState>>(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 <T> 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:")
}
}

94
Android/app/src/androidTest/java/com/dct/app/background/ProgramExitSessionTest.kt

@ -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<DctApplication>()
@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())
}
}

114
Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt

@ -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()
}
}

416
Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt

@ -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<WifiApDevice, String>? = 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<WifiApEndpoint?>(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()
}
}

69
Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt

@ -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) }
}
}

94
Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt

@ -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) }
}
}

51
Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt

@ -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()
}
}

67
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ChartNavigationUiTest.kt

@ -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<Double>()
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) }
}
}

167
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/CompensationLayoutUiTest.kt

@ -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<TextLayoutResult>()
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<TextLayoutResult>()
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)
}
}
}

115
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ConstantWindowUiTest.kt

@ -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<String, String>()
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<TextLayoutResult>()
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) }
}
}

26
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DebugValueResetUiTest.kt

@ -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()
}
}

32
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialogUiTest.kt

@ -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<Boolean>()
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()
}
}

42
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColorUiTest.kt

@ -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) }
}
}

258
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/FormulaColumnsUiTest.kt

@ -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<FormulaDefinition>()
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<FormulaDefinition>()
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<String, String>()
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) }
}
}

116
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt

@ -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<ChartRenderSnapshot>, open: MutableState<Boolean>,
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<Double, Double>? = 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.
}
}
}

80
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt

@ -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")
}
}

181
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt

@ -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) }
}
}

62
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonButtonUiTest.kt

@ -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<String>()
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) }
}
}

182
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenuUiTest.kt

@ -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<TextLayoutResult>()
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()
}
}

102
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiConnectionRibbonUiTest.kt

@ -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()
}
}

76
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiManualConnectionUiTest.kt

@ -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<String, Int>? = 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) }
}
}

144
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowActivationTest.kt

@ -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) }
}
}

76
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowParityUiTest.kt

@ -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<String, String>()
val visible = mutableStateOf(true)
lateinit var search: MutableState<String>
lateinit var expanded: MutableState<Boolean>
lateinit var checked: MutableState<Set<Long>>
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) }
}
}

534
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt

@ -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() }
}
}

85
Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/SignalTableTextAlignmentUiTest.kt

@ -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<TextLayoutResult>()
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)
}
}
}

163
Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableAutoFitUiTest.kt

@ -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<TextLayoutResult>()
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)
}
}

90
Android/app/src/androidTest/java/com/dct/app/presentation/signaltree/TableTextOverflowUiTest.kt

@ -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<TextLayoutResult>()
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))
}
}

291
Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt

@ -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<PacketRepository>()
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<PacketRepository>()
every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY)
val stream = MutableSharedFlow<DctPacket>(replay = 10, extraBufferCapacity = 16)
every { repo.packetStream } returns stream
val sentCommands = mutableListOf<DctPacket>()
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<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(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<DctPacket>()
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<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(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<DctPacket>()
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()
}
}

81
Android/app/src/test/java/com/dct/app/data/protocol/DctOtaProtocolTest.kt

@ -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)
}
}
}

292
Android/app/src/test/java/com/dct/app/presentation/maintenance/MaintenanceUiParityTest.kt

@ -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<PacketRepository>()
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<PacketRepository>()
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<PacketRepository>()
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}개 로그 파일 삭제 완료")
}
}

54
Android/app/src/test/java/com/dct/app/presentation/monitor/components/RibbonSubmenuPositionTest.kt

@ -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)
))
}
}

32
Android/app/src/test/java/com/dct/app/presentation/signaltree/SignalTableTextStylesTest.kt

@ -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)
}
}
}
}
}

78
Android/app/src/test/java/com/dct/app/presentation/signaltree/TableAutoFitTest.kt

@ -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))
}
}

113
Android/app/src/test/java/com/dct/app/tests/A2lCharacteristicLayoutTest.kt

@ -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)
}
}

223
Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt

@ -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)
}
}

106
Android/app/src/test/java/com/dct/app/tests/AlphanumericalUpdatesTest.kt

@ -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<Map<String, Double?>>()
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<Map<String, Double>>(emptyMap())
val received = mutableListOf<Map<String, Double?>>()
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)
}
}

213
Android/app/src/test/java/com/dct/app/tests/AttrConverterTest.kt

@ -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<IllegalArgumentException> {
AttrConverter.unpackRawValue(shortBuffer, 0, MdbAttrType.ATTR_4_UINT32)
}
// 오프셋 초과 -> 예외
assertThrows<IllegalArgumentException> {
AttrConverter.unpackRawValue(shortBuffer, 2, MdbAttrType.ATTR_2_UINT16)
}
}
}

236
Android/app/src/test/java/com/dct/app/tests/CalibrationEditorTest.kt

@ -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)
}
}

108
Android/app/src/test/java/com/dct/app/tests/CanDbcParserTest.kt

@ -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)
}
}

212
Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt

@ -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<CsvMeasurementLogger>).single {
it.outputFile!!.name.endsWith("_dbc.csv") }
val mf4 get() = (field(vm, "activeMf4Loggers").get(vm) as List<Mf4MeasurementLogger>).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<List<SignalDefinition>>).value = listOf(a, b)
val ui = field(vm, "_uiState").get(vm) as MutableStateFlow<MainUiState>
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<Int, ByteArray>, 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<CsvMeasurementLogger>
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)
}
}
}

44
Android/app/src/test/java/com/dct/app/tests/CanSignalKeyTest.kt

@ -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))
}
}

253
Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt

@ -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)
}
}

138
Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt

@ -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)
}
}

49
Android/app/src/test/java/com/dct/app/tests/ChartCursorTooltipTest.kt

@ -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)
}
}

90
Android/app/src/test/java/com/dct/app/tests/ChartInitialFillTest.kt

@ -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)
}
}

395
Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt

@ -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)
}
}

74
Android/app/src/test/java/com/dct/app/tests/ChartPerformanceLifecycleTest.kt

@ -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()
}
}

167
Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt

@ -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)
}
}

127
Android/app/src/test/java/com/dct/app/tests/ChartTelemetryResolverTest.kt

@ -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<SignalDefinition>) =
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<T>(private val items: List<T>) : AbstractList<T>() {
var reads = 0
override val size: Int get() = items.size
override fun get(index: Int): T {
reads++
return items[index]
}
}
}

248
Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt

@ -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)))
}
}

108
Android/app/src/test/java/com/dct/app/tests/ChecksumCalculatorTest.kt

@ -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<IllegalArgumentException> {
calculator.calculate(data, offset = -1, length = 5)
}
assertThrows<IllegalArgumentException> {
calculator.calculate(data, offset = 0, length = -1)
}
assertThrows<IllegalArgumentException> {
calculator.calculate(data, offset = 8, length = 5)
}
}
}

21
Android/app/src/test/java/com/dct/app/tests/ColorRefCompatibilityTest.kt

@ -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())
}
}

128
Android/app/src/test/java/com/dct/app/tests/CompensationLayoutTest.kt

@ -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<String, String>()
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"])
}
}

50
Android/app/src/test/java/com/dct/app/tests/ConstantPresentationTest.kt

@ -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))
}
}
}

218
Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt

@ -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()
}

170
Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt

@ -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()
}

61
Android/app/src/test/java/com/dct/app/tests/DctVersionResponseTest.kt

@ -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))
)
}
}
}

54
Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt

@ -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<String, Boolean>()
val prefs = mockk<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
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)
}
}

721
Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt

@ -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"))
}
}

107
Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt

@ -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 })
}
}

51
Android/app/src/test/java/com/dct/app/tests/FirmwareFileParserTest.kt

@ -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)
}
}
}

96
Android/app/src/test/java/com/dct/app/tests/FormulaEvaluatorTest.kt

@ -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<String, Double>()
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<String, Double>()
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<String, Double>()
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<String, Double>()
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"))
}
}

87
Android/app/src/test/java/com/dct/app/tests/FormulaIntegrationTest.kt

@ -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)
}
}

173
Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryEngineTest.kt

@ -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<String, Double>()
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<String, Double> by backing {
override val keys: MutableSet<String>
get() { enumerations++; return backing.keys }
override val entries: MutableSet<MutableMap.MutableEntry<String, Double>>
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())
}
}

371
Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt

@ -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<DctPacket>
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<DctPacket>
vm.setCurrentProject(DpcProject(measuredFilesFolder = directory.path, writeCsv = true, writeMf4 = false))
(field(fixture, "connectionState").get(fixture) as MutableStateFlow<DeviceConnectionState>).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<MainUiState>
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<MainUiState>
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<String, Number>
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<DctResult<Unit>>()
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<String, Number>
raw["can_1"] = 55
val recovery = repository.streamRecovery as MutableStateFlow<com.dct.app.domain.repository.StreamRecoveryState>
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<MainUiState>
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<String, Number>
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 })
}
}

31
Android/app/src/test/java/com/dct/app/tests/FormulaWindowStateTest.kt

@ -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<String, String>()
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)
}
}

168
Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt

@ -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)
}
}

223
Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt

@ -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<Triple<Double, Double, Boolean>>()
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<Triple<Double, Double, Boolean>>()
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<Boolean>()
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) })
}
}

79
Android/app/src/test/java/com/dct/app/tests/GraphTypeTest.kt

@ -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)
}
}

70
Android/app/src/test/java/com/dct/app/tests/HardwareWireRegressionTest.kt

@ -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)
}
}
}

137
Android/app/src/test/java/com/dct/app/tests/HeartbeatIdleRegressionTest.kt

@ -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<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 64)
val connected = MutableStateFlow(true)
val sent = mutableListOf<DctPacket>()
val acks = mutableListOf<DctPacket>()
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<DctPacket>()
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()
}
}

94
Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt

@ -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()
}
}

89
Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt

@ -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)
}
}

2018
Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt

File diff suppressed because it is too large

100
Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt

@ -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<PacketRepository>()
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<PacketRepository>()
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<PacketRepository>()
every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY)
coEvery { repo.sendCommand(any()) } coAnswers {
val request = firstArg<DctPacket>()
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<PacketRepository>()
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<PacketRepository>()
every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY)
val packetStream = kotlinx.coroutines.flow.MutableSharedFlow<DctPacket>(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)
}
}

127
Android/app/src/test/java/com/dct/app/tests/MdbRecordDecoderTest.kt

@ -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가 중복됩니다"))
}
}

253
Android/app/src/test/java/com/dct/app/tests/MdbRepositoryCrudTest.kt

@ -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)
}
}

246
Android/app/src/test/java/com/dct/app/tests/MdbSchemaParserTest.kt

@ -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}")
}
}
}
}
}

247
Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt

@ -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)
}
}

114
Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt

@ -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<DeviceConnectionState>
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<DeviceConnectionState>
connection.value = DeviceConnectionState.READY
}
@AfterEach fun cleanUp() {
vm.scope.cancel()
fixture.tearDown()
}
@Test fun timerStartsOnlyAfterSuccessfulStartNotWhileWaitingForResponse() {
val response = CompletableDeferred<DctResult<Unit>>()
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<DctResult<Unit>>()
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<StreamRecoveryState>
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)
}
}

89
Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt

@ -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)
}
}

31
Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt

@ -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))
}
}

244
Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt

@ -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()
}
}

100
Android/app/src/test/java/com/dct/app/tests/MeasurementRecordingConcurrencyTest.kt

@ -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<Pair<String, String>>() {
override val size = 1
override fun get(index: Int): Pair<String, String> {
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<CsvMeasurementLogger>).add(csv)
(field(vm, "activeMf4Loggers").get(vm) as MutableList<Mf4MeasurementLogger>).add(mf4)
val ui = field(vm, "_uiState").get(vm) as MutableStateFlow<MainUiState>
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()
}
}
}
}

251
Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt

@ -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<DctPacket>
private lateinit var state: MutableStateFlow<DeviceConnectionState>
private lateinit var loggers: MutableList<CsvMeasurementLogger>
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<DctPacket>
state = field(fixture, "connectionState").get(fixture) as MutableStateFlow<DeviceConnectionState>
loggers = field(vm, "activeCsvLoggers").get(vm) as MutableList<CsvMeasurementLogger>
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<com.dct.app.presentation.main.MainUiState>
ui.value = ui.value.copy(recordingState = MeasurementRecordingState.RECORDING,
recordingStartedAtMillis = 1234, recordingSampleCount = 10)
val recovery = repository.streamRecovery as MutableStateFlow<com.dct.app.domain.repository.StreamRecoveryState>
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<Pair<String, String>>() {
override val size = 1
override fun get(index: Int): Pair<String, String> {
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<Pair<String, String>>() {
override val size = 1
override fun get(index: Int): Pair<String, String> {
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<DctResult<Unit>>()
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<DctResult<Unit>>()
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<DctResult<Unit>>()
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<DctResult<Unit>>()
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)
}
}

396
Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt

@ -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<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 32)
val connected = MutableStateFlow(true)
var session = 7L
var owner = 42L
var nextSession = 8L
val sent = mutableListOf<DctPacket>()
val times = mutableListOf<Long>()
val acks = mutableListOf<DctPacket>()
val reconnects = mutableListOf<Pair<Long, Long>>()
val events = mutableListOf<String>()
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<Unit>()
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<String>() }
coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false; session = 0; owner++ }
coEvery { wifi.reconnectForRecovery(any(), any(), any()) } coAnswers {
reconnects += firstArg<Long>() to secondArg<Long>()
if (firstArg<Long>() != owner || !reconnectWorks) false else {
connected.value = false
yield()
session = nextSession++
connected.value = true
true
}
}
coEvery { wifi.sendCommand(any()) } coAnswers {
val request = firstArg<DctPacket>()
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()
}
}

168
Android/app/src/test/java/com/dct/app/tests/Mf4RecordingRegressionTest.kt

@ -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)
}
}

139
Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt

@ -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() }, "단절 마커가 다운샘플링 결과에 남아야 합니다")
}
}

203
Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt

@ -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)
}
}

123
Android/app/src/test/java/com/dct/app/tests/PacketFramingAndCodecTest.kt

@ -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()
}
}

23
Android/app/src/test/java/com/dct/app/tests/PacketPipelineMetricsTest.kt

@ -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)
}
}

166
Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt

@ -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<Int>()
val startedAt = mutableListOf<Long>()
val completedAt = mutableListOf<Long>()
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<Unit>()
val received = mutableListOf<Int>()
val done = CompletableDeferred<Unit>()
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<Int>()
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())
}
}

239
Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt

@ -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<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 16)
val connected = MutableStateFlow(true)
var sessionId = 7L
val sent = mutableListOf<DctPacket>()
val writtenAt = mutableListOf<Pair<DctPacket, Long>>()
val acks = mutableListOf<DctPacket>()
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<Long>() == sessionId && connected.value) { connected.value = false; true } else false
}
coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false }
coEvery { wifi.sendCommand(any()) } coAnswers {
val request = firstArg<DctPacket>()
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<DctPacket>()
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() })
}
}

659
Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt

@ -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<DctPacket>()
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<DctPacket>()
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<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(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<DctPacket>()
init {
every { wifi.isConnected } returns connected
every { wifi.packetStream } returns packets
every { wifi.currentSessionId } answers { sessionId }
every { wifi.invalidateConnectionIfSession(any()) } answers {
if (firstArg<Long>() != 0L && firstArg<Long>() == 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<DctPacket>()
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<DctPacket>().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<DctPacket>()
true
}
coEvery { f.wifi.sendCommand(match { it.command == 0x21.toByte() }) } coAnswers {
f.sentPackets += firstArg<DctPacket>()
// 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<DctPacket>()
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<DctPacket>()
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<DctPacket>()
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<DctPacket>()
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)
}
}

114
Android/app/src/test/java/com/dct/app/tests/PcCsvTimestampTest.kt

@ -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<MainUiState>
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<CsvMeasurementLogger>)
.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<CsvMeasurementLogger>)
.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()
}
}
}
}

158
Android/app/src/test/java/com/dct/app/tests/PcDpcCompatibilityTest.kt

@ -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"))
}
}

268
Android/app/src/test/java/com/dct/app/tests/PcMf4CompatibilityTest.kt

@ -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<MainUiState>
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<Mf4MeasurementLogger>)
.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))
}
}
}

189
Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt

@ -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<Throwable>()
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<Throwable>()
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<Unit>()
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") })
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save