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