134 changed files with 42 additions and 20974 deletions
@ -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:") |
|||
} |
|||
} |
|||
@ -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()) |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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. |
|||
} |
|||
} |
|||
} |
|||
@ -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") |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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) } |
|||
} |
|||
} |
|||
@ -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() } |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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)) |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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}개 로그 파일 삭제 완료") |
|||
} |
|||
} |
|||
@ -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) |
|||
)) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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)) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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)) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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] |
|||
} |
|||
} |
|||
} |
|||
@ -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))) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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()) |
|||
} |
|||
} |
|||
@ -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"]) |
|||
} |
|||
} |
|||
@ -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)) |
|||
} |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
@ -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() |
|||
} |
|||
@ -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)) |
|||
) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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")) |
|||
} |
|||
} |
|||
@ -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 }) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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")) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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()) |
|||
} |
|||
} |
|||
@ -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 }) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) }) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -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) |
|||
} |
|||
} |
|||
@ -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가 중복됩니다")) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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}") |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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)) |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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() }, "단절 마커가 다운샘플링 결과에 남아야 합니다") |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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()) |
|||
} |
|||
} |
|||
@ -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() }) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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")) |
|||
} |
|||
} |
|||
@ -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)) |
|||
} |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue