Browse Source
Preserve native MF4 types, compression and metadata; add compatibility regressions. Add CSV milliseconds selection, fixed PC-style TXT timestamps, submenu alignment and clearer recording controls.master
32 changed files with 2319 additions and 293 deletions
@ -0,0 +1,94 @@ |
|||
package com.dct.app.presentation.monitor.components |
|||
|
|||
import androidx.compose.foundation.background |
|||
import androidx.compose.foundation.layout.Row |
|||
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.test.* |
|||
import androidx.compose.ui.test.junit4.createComposeRule |
|||
import androidx.compose.ui.unit.Density |
|||
import org.junit.Assert.assertEquals |
|||
import org.junit.Assert.assertTrue |
|||
import org.junit.Rule |
|||
import org.junit.Test |
|||
|
|||
class RecordingRibbonButtonUiTest { |
|||
@get:Rule val compose = createComposeRule() |
|||
|
|||
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) } |
|||
} |
|||
} |
|||
@ -0,0 +1,182 @@ |
|||
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() |
|||
} |
|||
} |
|||
@ -0,0 +1,75 @@ |
|||
package com.dct.app.data.logger |
|||
|
|||
import com.dct.app.domain.model.mdb.SignalDefinition |
|||
|
|||
/** Raw channel layout from WriteA2LMDF4/WriteDBCMDF4's MDF4Writer.dll path. */ |
|||
data class PcMf4Channel( |
|||
val name: String, |
|||
val unit: String = "", |
|||
val dataType: Int = 4, // MDF: unsigned LE=0, signed LE=2, IEEE float LE=4. |
|||
val bitCount: Int = 64, |
|||
val valueKey: String = name, |
|||
val factor: Double = 1.0, |
|||
val offset: Double = 0.0, |
|||
val description: String = "", |
|||
val sourceName: String? = null, |
|||
val sourcePath: String = "", |
|||
val sourceComment: String = description, |
|||
val conversionType: Int? = null, |
|||
val conversionParameters: List<Double> = emptyList(), |
|||
val rawRange: Pair<Double, Double>? = null, |
|||
val physicalInput: Boolean = false |
|||
) { |
|||
init { |
|||
require(dataType in listOf(0, 2, 4)) |
|||
require(bitCount in listOf(8, 16, 32, 64)) |
|||
require(dataType != 4 || bitCount == 32 || bitCount == 64) |
|||
require(factor.isFinite() && offset.isFinite()) |
|||
require(conversionType == null || conversionType in listOf(1, 2, 4)) |
|||
require(conversionParameters.all { it.isFinite() }) |
|||
require(conversionType != 1 || conversionParameters.size == 2) |
|||
require(conversionType != 2 || conversionParameters.size == 6) |
|||
require(conversionType != 4 || (conversionParameters.size >= 2 && conversionParameters.size % 2 == 0)) |
|||
require(rawRange == null || (rawRange.first.isFinite() && rawRange.second.isFinite())) |
|||
} |
|||
|
|||
val byteSize: Int get() = bitCount / 8 |
|||
|
|||
companion object { |
|||
fun mdb(signal: SignalDefinition) = PcMf4Channel( |
|||
// DLL branch explicitly overrides every MDB channel to signed 32-bit. |
|||
signal.name, "", 2, 32, "sig_${signal.reclid}_${signal.idx}", |
|||
signal.cvsA, signal.cvsB) |
|||
|
|||
fun dbc(signal: SignalDefinition): PcMf4Channel { |
|||
// PC rounds DBC bit lengths to 8/16/32/64-bit storage buckets, always LE. |
|||
val bits = when (signal.bitLength) { in 1..8 -> 8; in 9..16 -> 16; in 17..32 -> 32; else -> 64 } |
|||
// PC DLL path deliberately leaves DBC unit/description empty. |
|||
return PcMf4Channel(signal.name, "", if (signal.attr.isSigned) 2 else 0, |
|||
bits, com.dct.app.domain.model.mdb.CanSignalKey.stable(signal), |
|||
signal.cvsA, signal.cvsB) |
|||
} |
|||
|
|||
fun a2l(signal: SignalDefinition, |
|||
metadata: Pair<com.dct.app.data.parser.A2lParser.A2lMeasurement?, com.dct.app.data.parser.A2lParser.CompuMethod?>? = null): PcMf4Channel { |
|||
val method = metadata?.second |
|||
val channel = PcMf4Channel( |
|||
signal.name, metadata?.first?.unit?.takeIf { it.isNotBlank() } |
|||
?: method?.unit?.takeIf { it.isNotBlank() } ?: signal.unit, |
|||
signal.a2lDataType?.let { if (it.isFloat) 4 else if (it.isSigned) 2 else 0 } |
|||
?: if (signal.attr.isSigned) 2 else 0, |
|||
signal.a2lDataType?.let { if (it.isFloat && it.byteSize == 2) 32 else it.byteSize * 8 } |
|||
?: signal.attr.byteSize * 8, |
|||
signal.name, signal.cvsA, signal.cvsB, signal.description, |
|||
signal.name, metadata?.first?.symbolLink?.takeIf { it.isNotBlank() } ?: signal.name, signal.description, |
|||
rawRange = signal.min to signal.max) |
|||
return when (method?.conversionType?.uppercase()) { |
|||
"RAT_FUNC" -> channel.copy(conversionType = 2, conversionParameters = listOf( |
|||
method.cvsA, method.cvsB, method.cvsC, method.cvsD, method.cvsE, method.cvsF)) |
|||
"TAB_INTP" -> if (method.tabIntpPoints.isEmpty()) channel else channel.copy( |
|||
conversionType = 4, conversionParameters = method.tabIntpPoints.flatMap { listOf(it.first, it.second) }) |
|||
else -> channel |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
package com.dct.app.presentation.monitor.components |
|||
|
|||
import androidx.compose.foundation.Canvas |
|||
import androidx.compose.foundation.layout.Box |
|||
import androidx.compose.foundation.layout.size |
|||
import androidx.compose.material.icons.Icons |
|||
import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile |
|||
import androidx.compose.material3.Icon |
|||
import androidx.compose.material3.IconButton |
|||
import androidx.compose.runtime.Composable |
|||
import androidx.compose.ui.Modifier |
|||
import androidx.compose.ui.geometry.CornerRadius |
|||
import androidx.compose.ui.geometry.Offset |
|||
import androidx.compose.ui.geometry.Size |
|||
import androidx.compose.ui.graphics.Color |
|||
import androidx.compose.ui.semantics.contentDescription |
|||
import androidx.compose.ui.semantics.semantics |
|||
import androidx.compose.ui.unit.dp |
|||
|
|||
/** File recording stays distinct from measurement play/stop and project Save. */ |
|||
@Composable |
|||
internal fun RecordingRibbonButton( |
|||
tooltip: String, |
|||
enabled: Boolean, |
|||
isStop: Boolean = false, |
|||
onClick: () -> Unit |
|||
) { |
|||
val ink = if (enabled) Color(0xFF374151) else ColorOutlineVariant.copy(alpha = 0.5f) |
|||
val badge = if (!enabled || isStop) ink else Color(0xFFDC2626) |
|||
IconButton( |
|||
onClick = onClick, |
|||
enabled = enabled, |
|||
modifier = Modifier.size(32.dp).semantics { contentDescription = tooltip } |
|||
) { |
|||
Box(Modifier.size(24.dp)) { |
|||
Icon( |
|||
imageVector = Icons.AutoMirrored.Outlined.InsertDriveFile, |
|||
contentDescription = null, |
|||
tint = ink, |
|||
modifier = Modifier.matchParentSize() |
|||
) |
|||
Canvas(Modifier.matchParentSize()) { |
|||
val unit = size.width / 24f |
|||
val center = Offset(17f * unit, 17f * unit) |
|||
// The ribbon surface is white; clear the file outline behind the badge. |
|||
if (isStop) { |
|||
drawRect( |
|||
color = Color.White, |
|||
topLeft = Offset(10f * unit, 10f * unit), |
|||
size = Size(14f * unit, 14f * unit) |
|||
) |
|||
drawRoundRect( |
|||
color = badge, |
|||
topLeft = Offset(11f * unit, 11f * unit), |
|||
size = Size(12f * unit, 12f * unit), |
|||
cornerRadius = CornerRadius(0.3f * unit) |
|||
) |
|||
} else { |
|||
drawCircle(Color.White, radius = 7f * unit, center = center) |
|||
drawCircle(badge, radius = 6f * unit, center = center) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,197 @@ |
|||
package com.dct.app.presentation.monitor.components |
|||
|
|||
import androidx.compose.foundation.background |
|||
import androidx.compose.foundation.BorderStroke |
|||
import androidx.compose.foundation.border |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.foundation.rememberScrollState |
|||
import androidx.compose.foundation.verticalScroll |
|||
import androidx.compose.material.icons.Icons |
|||
import androidx.compose.material.icons.filled.ChevronRight |
|||
import androidx.compose.material3.* |
|||
import androidx.compose.runtime.* |
|||
import androidx.compose.ui.Modifier |
|||
import androidx.compose.ui.graphics.Color |
|||
import androidx.compose.ui.platform.LocalView |
|||
import androidx.compose.ui.platform.testTag |
|||
import androidx.compose.ui.semantics.semantics |
|||
import androidx.compose.ui.semantics.selected |
|||
import androidx.compose.ui.text.style.TextOverflow |
|||
import androidx.compose.ui.unit.* |
|||
import androidx.compose.ui.window.Popup |
|||
import androidx.compose.ui.window.PopupPositionProvider |
|||
import androidx.compose.ui.window.PopupProperties |
|||
import com.dct.app.domain.model.chart.InterpolationMode |
|||
|
|||
private enum class RibbonSettingsCategory(val label: String) { |
|||
GRAPH("Graph Type"), RECORDING("Measurement Recording"), AUTOSTART("계측과 함께 측정"), CSV_MILLISECONDS("CSV 밀리초") |
|||
} |
|||
|
|||
internal val RibbonMenuBorderColor = Color(0xFF94A3B8) |
|||
|
|||
@Composable |
|||
internal fun RibbonFileMenu( |
|||
expanded: Boolean, |
|||
onDismissRequest: () -> Unit, |
|||
content: @Composable ColumnScope.() -> Unit |
|||
) { |
|||
DropdownMenu( |
|||
expanded = expanded, |
|||
onDismissRequest = onDismissRequest, |
|||
modifier = Modifier.testTag("RibbonFileMenu") |
|||
.background(Color.White, MaterialTheme.shapes.extraSmall) |
|||
.border(1.dp, RibbonMenuBorderColor, MaterialTheme.shapes.extraSmall), |
|||
content = content |
|||
) |
|||
} |
|||
|
|||
/** Anchor to the selected row, not below the entire hamburger menu. */ |
|||
internal class RibbonSubmenuPositionProvider( |
|||
private val parentWindowOffset: () -> IntOffset = { IntOffset.Zero } |
|||
) : PopupPositionProvider { |
|||
override fun calculatePosition( |
|||
anchorBounds: IntRect, |
|||
windowSize: IntSize, |
|||
layoutDirection: LayoutDirection, |
|||
popupContentSize: IntSize |
|||
): IntOffset { |
|||
// A nested Popup supplies bounds relative to the parent popup's window, not the screen. |
|||
val screenAnchor = anchorBounds.translate(parentWindowOffset()) |
|||
// Prefer the requested right-hand cascade; keep it reachable on a narrow screen. |
|||
val x = if (screenAnchor.right + popupContentSize.width <= windowSize.width) { |
|||
screenAnchor.right |
|||
} else { |
|||
screenAnchor.left - popupContentSize.width |
|||
} |
|||
return IntOffset( |
|||
x.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)), |
|||
screenAnchor.top.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)) |
|||
) |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
internal fun RibbonSettingsMenuCategories( |
|||
menuExpanded: Boolean, |
|||
graphMode: InterpolationMode, |
|||
recordingFormatsEnabled: Boolean, |
|||
writeCsv: Boolean, |
|||
writeMf4: Boolean, |
|||
autostartRecording: Boolean, |
|||
recordingFolder: String, |
|||
onGraphModeChange: (InterpolationMode) -> Unit, |
|||
onRecordingFormatsChange: (Boolean, Boolean) -> Unit, |
|||
onAutostartRecordingChange: (Boolean) -> Unit, |
|||
onDismissMenu: () -> Unit, |
|||
csvWithMilliseconds: Boolean, |
|||
onCsvWithMillisecondsChange: (Boolean) -> Unit |
|||
) { |
|||
val parentView = LocalView.current |
|||
val positionProvider = remember(parentView) { |
|||
val screenLocation = IntArray(2) |
|||
val windowLocation = IntArray(2) |
|||
RibbonSubmenuPositionProvider { |
|||
parentView.getLocationOnScreen(screenLocation) |
|||
parentView.getLocationInWindow(windowLocation) |
|||
IntOffset(screenLocation[0] - windowLocation[0], screenLocation[1] - windowLocation[1]) |
|||
} |
|||
} |
|||
var selectedCategory by remember { mutableStateOf<RibbonSettingsCategory?>(null) } |
|||
LaunchedEffect(menuExpanded, recordingFormatsEnabled) { |
|||
if (!menuExpanded || (!recordingFormatsEnabled && selectedCategory == RibbonSettingsCategory.RECORDING)) { |
|||
selectedCategory = null |
|||
} |
|||
} |
|||
val dismissAll = { |
|||
selectedCategory = null |
|||
onDismissMenu() |
|||
} |
|||
RibbonSettingsCategory.entries.forEach { category -> |
|||
val enabled = category != RibbonSettingsCategory.RECORDING || recordingFormatsEnabled |
|||
val color = if (enabled) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f) |
|||
Box(Modifier.testTag("RibbonSettingsCategory_${category.name}")) { |
|||
DropdownMenuItem( |
|||
enabled = enabled, |
|||
modifier = Modifier.background( |
|||
if (selectedCategory == category && enabled) Color(0xFFEFF6FF) else Color.Transparent |
|||
), |
|||
text = { |
|||
Text(category.label, color = color, fontSize = 13.sp) |
|||
}, |
|||
trailingIcon = { |
|||
Icon(Icons.Default.ChevronRight, null, tint = color, modifier = Modifier.size(18.dp)) |
|||
}, |
|||
onClick = { selectedCategory = if (selectedCategory == category) null else category } |
|||
) |
|||
if (menuExpanded && selectedCategory == category && enabled) { |
|||
Popup( |
|||
popupPositionProvider = positionProvider, |
|||
onDismissRequest = { selectedCategory = null }, |
|||
// The parent owns focus/Back. Outside touches can still select a sibling category. |
|||
properties = PopupProperties(focusable = false) |
|||
) { |
|||
Surface( |
|||
modifier = Modifier.testTag("RibbonSettingsSubmenu"), |
|||
color = Color.White, |
|||
shape = MaterialTheme.shapes.extraSmall, |
|||
border = BorderStroke(1.dp, RibbonMenuBorderColor), |
|||
shadowElevation = 8.dp |
|||
) { |
|||
Column(Modifier.width(240.dp).heightIn(max = 360.dp) |
|||
.verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) { |
|||
when (category) { |
|||
RibbonSettingsCategory.GRAPH -> { |
|||
listOf("Linear" to InterpolationMode.LINEAR, "Pulse" to InterpolationMode.PWM_STEP) |
|||
.forEach { (label, mode) -> |
|||
DropdownMenuItem( |
|||
text = { Text(label, fontSize = 13.sp) }, |
|||
leadingIcon = { RadioButton(graphMode == mode, onClick = null) }, |
|||
onClick = { onGraphModeChange(mode); dismissAll() } |
|||
) |
|||
} |
|||
} |
|||
RibbonSettingsCategory.RECORDING -> { |
|||
DropdownMenuItem( |
|||
text = { Text("Write CSV", fontSize = 13.sp) }, |
|||
leadingIcon = { Checkbox(writeCsv, onCheckedChange = null) }, |
|||
onClick = { onRecordingFormatsChange(!writeCsv, writeMf4) } |
|||
) |
|||
DropdownMenuItem( |
|||
text = { Text("Write MF4", fontSize = 13.sp) }, |
|||
leadingIcon = { Checkbox(writeMf4, onCheckedChange = null) }, |
|||
onClick = { onRecordingFormatsChange(writeCsv, !writeMf4) } |
|||
) |
|||
if (recordingFolder.isNotBlank()) Text( |
|||
"Folder: $recordingFolder", fontSize = 10.sp, color = ColorTextSecondary, |
|||
maxLines = 2, overflow = TextOverflow.Ellipsis, |
|||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) |
|||
) |
|||
} |
|||
RibbonSettingsCategory.AUTOSTART -> { |
|||
listOf(true to "On", false to "Off").forEach { (value, label) -> |
|||
DropdownMenuItem( |
|||
text = { Text(label, fontSize = 13.sp) }, |
|||
leadingIcon = { RadioButton(autostartRecording == value, onClick = null) }, |
|||
onClick = { onAutostartRecordingChange(value); dismissAll() } |
|||
) |
|||
} |
|||
} |
|||
RibbonSettingsCategory.CSV_MILLISECONDS -> { |
|||
listOf(true to "True", false to "False").forEach { (value, label) -> |
|||
DropdownMenuItem( |
|||
modifier = Modifier.testTag("CsvMilliseconds_$value") |
|||
.semantics { selected = csvWithMilliseconds == value }, |
|||
text = { Text(label, fontSize = 13.sp) }, |
|||
leadingIcon = { RadioButton(csvWithMilliseconds == value, onClick = null) }, |
|||
onClick = { onCsvWithMillisecondsChange(value); dismissAll() } |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,54 @@ |
|||
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) |
|||
)) |
|||
} |
|||
} |
|||
@ -0,0 +1,100 @@ |
|||
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() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,168 @@ |
|||
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) |
|||
} |
|||
} |
|||
@ -0,0 +1,114 @@ |
|||
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() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,268 @@ |
|||
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)) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
// Read-only reference harness for the PC's bundled Windows x86 MDF4Writer.dll.
|
|||
// Mirrors WriteA2LMDF4.cpp's DLL branch and accepts common raw input JSON.
|
|||
using System; |
|||
using System.Collections; |
|||
using System.Collections.Generic; |
|||
using System.Globalization; |
|||
using System.IO; |
|||
using System.Runtime.InteropServices; |
|||
using System.Web.Script.Serialization; |
|||
|
|||
class PcMdf4Reference { |
|||
const string Dll = "MDF4Writer.dll"; |
|||
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern bool SetDllDirectory(string path); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern IntPtr M4WRInitDll(); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern void M4WRExitDll(IntPtr h); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern void M4WRSetPathName(IntPtr h, string path); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRCreateMDF4(IntPtr h, string tool, string vendor, string version, string comment, IntPtr mdfVersion); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRFileDescriptionEx(IntPtr h, string comment, int quality, double start, ushort tz, ushort dst, byte flags); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern void M4WRAddGroup64(IntPtr h, string name, long count, out IntPtr group); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRAddTimeSignal(IntPtr h, int group, string name, int type, int first, int bits, string unit, double factor, double offset, out int signal); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRAddSignal(IntPtr h, int group, string name, string label, int type, int first, int bits, string unit, double factor, double offset, int hasNoValue, double noValue, int invalidBit, out int signal); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRSignalDescription(IntPtr h, int group, int signal, string name, string path, string comment); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRMakeGroups(IntPtr h); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRSetSignalValue(IntPtr h, int group, int signal, double value, int noValue); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRWriteRecord(IntPtr h, int group); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRSetMinMax(IntPtr h, int group, int signal, double min, double max); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRFlushGroup(IntPtr h, int group); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern void M4WRClose(IntPtr h); |
|||
[DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern void M4WRSetNanoTimeUTC(IntPtr h, long time); |
|||
static double Number(object o) { return Convert.ToDouble(o, CultureInfo.InvariantCulture); } |
|||
static int Main(string[] args) { |
|||
if (args.Length != 3) throw new ArgumentException("dll-directory input-json new-output-mf4"); |
|||
if (File.Exists(args[2])) throw new IOException("Refusing to overwrite existing reference output"); |
|||
SetDllDirectory(Path.GetFullPath(args[0])); |
|||
var json = new JavaScriptSerializer { MaxJsonLength = Int32.MaxValue }; |
|||
var data = json.Deserialize<Dictionary<string, object>>(File.ReadAllText(args[1])); |
|||
var channels = (ArrayList)data["channels"]; |
|||
var ids = new int[channels.Count]; |
|||
IntPtr h = M4WRInitDll(); |
|||
try { |
|||
M4WRSetPathName(h, Path.GetFullPath(args[2])); |
|||
if (!M4WRCreateMDF4(h, "DCT", "Doowon Climate Control", "1.0", "", new IntPtr(410))) throw new Exception("CreateMDF4 failed"); |
|||
M4WRSetNanoTimeUTC(h, Convert.ToInt64(data["start_ns"])); |
|||
if (!M4WRFileDescriptionEx(h, "<HDcomment></HDcomment>", -1, Number(data["start_ns"])/1000000000.0, |
|||
Convert.ToUInt16(data["timezone_minutes"]), Convert.ToUInt16(data["dst_minutes"]), Convert.ToByte(data["time_flags"]))) throw new Exception("FileDescription failed"); |
|||
IntPtr group; |
|||
M4WRAddGroup64(h, "Measurements", 10000, out group); |
|||
int g = group.ToInt32(); |
|||
if (!M4WRAddTimeSignal(h, g, "Time", 4, 0, 64, "s", 1, 0, out ids[0])) throw new Exception("Time failed"); |
|||
for (int i=1; i<channels.Count; i++) { |
|||
var c = (Dictionary<string,object>)channels[i]; |
|||
double factor=1, offset=0; |
|||
if (c["conversion"] != null) { |
|||
var cc=(Dictionary<string,object>)c["conversion"]; |
|||
if (Convert.ToInt32(cc["type"])!=1) throw new Exception("DLL AddSignal reference only supports linear conversion"); |
|||
var p=(ArrayList)cc["parameters"]; offset=Number(p[0]); factor=Number(p[1]); |
|||
} |
|||
if (!M4WRAddSignal(h,g,(string)c["name"],(string)c["description"],Convert.ToInt32(c["data_type"]),-1,Convert.ToInt32(c["bit_count"]),(string)c["unit"],factor,offset,0,0,-1,out ids[i])) throw new Exception("Signal failed: "+c["name"]); |
|||
if ((Convert.ToInt32(c["flags"]) & 8) != 0) |
|||
M4WRSetMinMax(h,g,ids[i],Number(c["min_raw"]),Number(c["max_raw"])); |
|||
if (c["source"]!=null) { |
|||
var s=(Dictionary<string,object>)c["source"]; |
|||
M4WRSignalDescription(h,g,ids[i],(string)s["name"],(string)s["path"],(string)s["comment"]); |
|||
} |
|||
} |
|||
if (!M4WRMakeGroups(h)) throw new Exception("MakeGroups failed"); |
|||
// PC WriteData reads every EnvSetting item's current value on every record.
|
|||
// That state survives unreceived messages; the DLL's record buffer itself resets.
|
|||
var currentValues = new double[ids.Length]; |
|||
foreach(ArrayList record in (ArrayList)data["records"]) { |
|||
for(int i=0; i<ids.Length; i++) { |
|||
if (record[i] != null) currentValues[i]=Number(record[i]); |
|||
if (!M4WRSetSignalValue(h,g,ids[i],currentValues[i],0)) throw new Exception("SetSignalValue failed"); |
|||
} |
|||
if (!M4WRWriteRecord(h,g)) throw new Exception("WriteRecord failed"); |
|||
} |
|||
M4WRFlushGroup(h,g); M4WRClose(h); |
|||
Console.WriteLine("PC DLL reference generated: "+args[2]); |
|||
} finally { M4WRExitDll(h); } |
|||
return 0; |
|||
} |
|||
} |
|||
@ -0,0 +1,193 @@ |
|||
"""Export the supplied PC MF4 as common raw input, inspect/compare reader semantics. |
|||
|
|||
Requires the independent asammdf reader, not an Android dependency. Original files |
|||
are read-only. The exported input also drives the actual bundled PC writer DLL. |
|||
""" |
|||
import argparse |
|||
import json |
|||
import struct |
|||
import xml.etree.ElementTree as ET |
|||
from collections import Counter |
|||
from pathlib import Path |
|||
|
|||
import numpy as np |
|||
from asammdf import MDF |
|||
|
|||
|
|||
def blocks(path): |
|||
data = path.read_bytes() |
|||
visited, counts, compression = set(), Counter(), [] |
|||
|
|||
def visit(address): |
|||
if not address or address in visited: |
|||
return |
|||
visited.add(address) |
|||
kind, _, length, links_nr = struct.unpack_from('<4sIQQ', data, address) |
|||
assert address + length <= len(data), (address, kind, length) |
|||
counts[kind.decode()] += 1 |
|||
links = struct.unpack_from('<' + 'Q' * links_nr, data, address + 24) |
|||
if kind == b'##DZ': |
|||
original, zip_type, _, parameter, raw_size, zip_size = struct.unpack_from('<2sBBIQQ', data, address + 24) |
|||
compression.append(dict(original=original.decode(), zip_type=zip_type, |
|||
parameter=parameter, raw_size=raw_size, zip_size=zip_size)) |
|||
for link in links: |
|||
visit(link) |
|||
|
|||
visit(64) |
|||
return dict(counts=dict(counts), compression=compression, file_size=len(data)) |
|||
|
|||
|
|||
def channel(c): |
|||
conversion = c.conversion |
|||
cc = None if conversion is None else dict( |
|||
type=conversion.conversion_type, |
|||
parameters=[getattr(conversion, key) for key in ('b', 'a')] |
|||
if conversion.conversion_type == 1 else []) |
|||
source = None if c.source is None else { |
|||
key: getattr(c.source, key) for key in ('name', 'path', 'comment', 'source_type', 'bus_type')} |
|||
return dict(name=c.name, unit=c.unit, description=c.comment, |
|||
data_type=c.data_type, bit_count=c.bit_count, bit_offset=c.bit_offset, |
|||
byte_offset=c.byte_offset, flags=c.flags, conversion=cc, source=source, |
|||
min_raw=c.min_raw_value, max_raw=c.max_raw_value) |
|||
|
|||
|
|||
def export(source, destination): |
|||
with MDF(source) as m: |
|||
assert len(m.groups) == 1, 'The reference fixture requires one PC channel group' |
|||
g = m.groups[0] |
|||
signals = [m.get(group=0, index=i, raw=True) for i in range(1, len(g.channels))] |
|||
times = m.get_master(0) |
|||
result = dict(reference=str(source), version=m.version, start_ns=m.header.abs_time, |
|||
timezone_minutes=m.header.tz_offset, dst_minutes=m.header.daylight_save_time, |
|||
time_flags=m.header.time_flags, header_comment=m.header.comment, |
|||
history=[h.comment for h in m.file_history], |
|||
channels=[channel(c) for c in g.channels], |
|||
record_size=g.channel_group.samples_byte_nr, |
|||
records=[[float(t)] + [s.samples[i].item() for s in signals] |
|||
for i, t in enumerate(times)], blocks=blocks(source)) |
|||
destination.parent.mkdir(parents=True, exist_ok=True) |
|||
destination.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8') |
|||
print('EXPORTED', len(result['channels']), 'channels,', len(result['records']), 'records,', result['record_size'], 'bytes') |
|||
print(json.dumps(result['blocks'], indent=2)) |
|||
specs = [('U8', 0, 8, [0, 255, 17]), ('S8', 2, 8, [-128, 127, -3]), |
|||
('U16', 0, 16, [0, 65535, 12345]), ('S16', 2, 16, [-32768, 32767, -134]), |
|||
('U32', 0, 32, [0, 4294967295, 3735928559]), ('S32', 2, 32, [-2147483648, 2147483647, -10]), |
|||
('U64', 0, 64, [0, 9007199254740992, 123456789]), ('S64', 2, 64, [-9007199254740992, 9007199254740992, -9]), |
|||
('F32', 4, 32, [-12.5, 3.75, 1.25]), ('F64', 4, 64, [-1.25, 3.141592653589793, 0.125]), |
|||
('Temperature', 2, 16, [-300, 125, 1000])] |
|||
synthetic = {k: v for k, v in result.items() if k not in ('records', 'channels', 'blocks')} |
|||
offset = 8 |
|||
synthetic['channels'] = [result['channels'][0]] |
|||
for name, dtype, bits, values in specs: |
|||
synthetic['channels'].append(dict(name=name, unit='degC' if name == 'Temperature' else 'V', |
|||
description='raw boundary '+name, data_type=dtype, bit_count=bits, bit_offset=0, |
|||
byte_offset=offset, flags=0, min_raw=0., max_raw=0., conversion=dict(type=1, parameters=[-40., .1]) |
|||
if name == 'Temperature' else None, |
|||
source=dict(name=name, path='ECU.'+name, comment='raw boundary '+name, source_type=3, bus_type=0))) |
|||
offset += bits // 8 |
|||
synthetic['record_size'] = offset |
|||
synthetic['records'] = [[time] + [s[3][i] for s in specs] for i, time in enumerate([-.01, 0., .125])] |
|||
synthetic['records'][2][1] = None # PC EnvSetting retains raw values and submits every field to the DLL. |
|||
synthetic['records'][2][-1] = None |
|||
destination.with_name('synthetic.json').write_text(json.dumps(synthetic, ensure_ascii=False, indent=2), encoding='utf-8') |
|||
|
|||
|
|||
def compare(reference, candidate): |
|||
with MDF(reference) as left, MDF(candidate) as right: |
|||
assert left.version == right.version |
|||
assert len(left.groups) == len(right.groups) |
|||
assert left.header.abs_time == right.header.abs_time |
|||
assert (left.header.tz_offset, left.header.daylight_save_time, left.header.time_flags) == ( |
|||
right.header.tz_offset, right.header.daylight_save_time, right.header.time_flags) |
|||
assert left.header.comment == right.header.comment |
|||
assert len(left.file_history) == len(right.file_history) > 0 |
|||
for a, b in zip(left.file_history, right.file_history): |
|||
x, y = ET.fromstring(a.comment), ET.fromstring(b.comment) |
|||
for key in ('TX', 'tool_id', 'tool_vendor', 'tool_version'): |
|||
assert x.findtext(key) == y.findtext(key), ('File History', key) |
|||
original = x.find("./common_properties/e[@name='Free_MDF4_Lib']") |
|||
if original is not None: |
|||
same_writer = y.find("./common_properties/e[@name='Free_MDF4_Lib']") |
|||
if same_writer is not None: |
|||
assert original.text == same_writer.text |
|||
else: |
|||
# Android must identify its actual writer, not impersonate the Windows DLL. |
|||
assert y.findtext("./common_properties/e[@name='writer']") == 'DCT Android; MDF4Writer.dll-compatible' |
|||
assert y.findtext("./common_properties/e[@name='reference_writer']") == 'Free_MDF4_Lib ' + original.text |
|||
for group_index, (a, b) in enumerate(zip(left.groups, right.groups)): |
|||
for key in ('comment', 'record_id_len'): |
|||
assert getattr(a.data_group, key) == getattr(b.data_group, key), ('DG', key) |
|||
for key in ('acq_name', 'comment', 'flags', 'record_id', 'invalidation_bytes_nr'): |
|||
assert getattr(a.channel_group, key) == getattr(b.channel_group, key), ('CG', key) |
|||
assert a.channel_group.samples_byte_nr == b.channel_group.samples_byte_nr |
|||
assert a.channel_group.cycles_nr == b.channel_group.cycles_nr |
|||
assert len(a.channels) == len(b.channels) |
|||
for index, (x, y) in enumerate(zip(a.channels, b.channels)): |
|||
cx, cy = channel(x), channel(y) |
|||
for key in ('name', 'unit', 'description', 'data_type', 'bit_count', 'bit_offset', |
|||
'byte_offset', 'conversion', 'source', 'flags', 'min_raw', 'max_raw'): |
|||
assert cx[key] == cy[key], (index, x.name, key, cx[key], cy[key]) |
|||
np.testing.assert_array_equal(left.get(group=group_index, index=index, raw=True).samples, |
|||
right.get(group=group_index, index=index, raw=True).samples) |
|||
np.testing.assert_allclose(left.get(group=group_index, index=index).samples, |
|||
right.get(group=group_index, index=index).samples, rtol=0, atol=1e-12) |
|||
np.testing.assert_array_equal(left.get_master(group_index), right.get_master(group_index)) |
|||
print('PASS: version, HD/FH metadata (explicit actual writer exception), epoch/timezone, DG/CG metadata, channel order/types/bits/offsets, record size, count, raw/physical values, Time, units/descriptions/SI/conversion') |
|||
print('HEADER COMMENTS', left.header.comment == right.header.comment) |
|||
print('HISTORY LEFT', [h.comment for h in left.file_history]) |
|||
print('HISTORY RIGHT', [h.comment for h in right.file_history]) |
|||
reference_blocks, candidate_blocks = blocks(reference), blocks(candidate) |
|||
for kind in ('##HD', '##DG', '##CG', '##CN', '##SI', '##CC', '##FH', '##MD', '##HL'): |
|||
assert reference_blocks['counts'].get(kind, 0) == candidate_blocks['counts'].get(kind, 0), kind |
|||
sizes = {g.channel_group.samples_byte_nr for g in right.groups} |
|||
for info in (reference_blocks, candidate_blocks): |
|||
assert info['counts'].get('##DT', 0) == 0, 'PC profile requires compressed data blocks' |
|||
for dz in info['compression']: |
|||
assert dz['original'] == 'DT' and dz['zip_type'] == 1 |
|||
assert dz['parameter'] in sizes and dz['raw_size'] % dz['parameter'] == 0 |
|||
assert sum(d['raw_size'] for d in reference_blocks['compression']) == sum( |
|||
d['raw_size'] for d in candidate_blocks['compression']) |
|||
print('PASS: semantic block counts and transposed zlib compression; TX deduplication, DL chunking, compressed bytes and actual writer may differ') |
|||
print('REFERENCE BLOCKS', json.dumps(reference_blocks)) |
|||
print('CANDIDATE BLOCKS', json.dumps(candidate_blocks)) |
|||
|
|||
|
|||
def verify_fixtures(root): |
|||
# These files are generated by the Android implementation, but decoded only by asammdf. |
|||
for name, count, width in [('chunks.mf4', 10003, 10), ('wide-pc.mf4', 400, 19764), |
|||
('empty-pc.mf4', 0, 10), ('queued-pc.mf4', 1, 10)]: |
|||
with MDF(root / name) as m: |
|||
g = m.groups[0] |
|||
assert g.channel_group.cycles_nr == count |
|||
assert g.channel_group.samples_byte_nr == width |
|||
expected_time = [-.01] if name == 'queued-pc.mf4' else np.arange(count) / 1000 |
|||
np.testing.assert_array_equal(m.get_master(0), expected_time) |
|||
if count: |
|||
expected_values = [-2] if name == 'queued-pc.mf4' else np.arange(count) |
|||
np.testing.assert_array_equal(m.get(group=0, index=1, raw=True).samples, expected_values) |
|||
if name == 'wide-pc.mf4': |
|||
assert len(g.channels) == 4940 |
|||
np.testing.assert_array_equal(m.get(group=0, index=4939, raw=True).samples, np.zeros(count)) |
|||
print('PASS independent reader:', name, 'samples', count, 'record bytes', width) |
|||
with MDF(root / 'conversions-pc.mf4') as m: |
|||
np.testing.assert_array_equal(m.get('Rational', raw=True).samples, [2, 5]) |
|||
np.testing.assert_array_equal(m.get('Table', raw=True).samples, [2, 5]) |
|||
np.testing.assert_allclose(m.get('Rational').samples, [11, 38], rtol=0, atol=1e-12) |
|||
np.testing.assert_allclose(m.get('Table').samples, [20, 50], rtol=0, atol=1e-12) |
|||
assert m.groups[0].channels[1].source.path == 'ECU.Rational' |
|||
assert m.groups[0].channels[1].unit == 'degC' |
|||
print('PASS independent reader: A2L factory, rational and interpolated CC, raw/physical values, original unit/SI') |
|||
|
|||
|
|||
if __name__ == '__main__': |
|||
parser = argparse.ArgumentParser(description=__doc__) |
|||
parser.add_argument('action', choices=['export', 'compare', 'fixtures']) |
|||
parser.add_argument('source', type=Path) |
|||
parser.add_argument('target', type=Path, nargs='?') |
|||
args = parser.parse_args() |
|||
if args.action == 'fixtures': |
|||
verify_fixtures(args.source) |
|||
else: |
|||
if args.target is None: |
|||
parser.error('export/compare requires target') |
|||
(export if args.action == 'export' else compare)(args.source, args.target) |
|||
@ -0,0 +1,94 @@ |
|||
"""Independent MDF 4.10 check of artifacts exported by Mf4RecordingRegressionTest. |
|||
|
|||
Set DCT_RECORDING_FIXTURES_DIR when running the JUnit test, then run this script |
|||
with that directory. Requires asammdf; it is not an Android/build dependency. |
|||
""" |
|||
import argparse |
|||
import csv |
|||
from datetime import datetime |
|||
from pathlib import Path |
|||
|
|||
import numpy as np |
|||
from asammdf import MDF |
|||
|
|||
|
|||
def verify(directory): |
|||
checks = [] |
|||
with MDF(directory / 'relative.mf4') as recording: |
|||
assert recording.version == '4.10' |
|||
assert len(recording.groups) == 1 |
|||
assert recording.groups[0].channel_group.cycles_nr == 2 |
|||
temperature = recording.get('온도') |
|||
assert temperature.unit == '°C' |
|||
np.testing.assert_array_equal(temperature.samples, [-12.5, 3.75]) |
|||
np.testing.assert_allclose(temperature.timestamps, [0.125, 0.5], atol=1e-6) |
|||
np.testing.assert_array_equal(recording.get('Speed').samples, [87.25, 88.0]) |
|||
assert np.isnan(recording.get('Missing').samples).all() |
|||
checks.append('relative time, names, units, negative/fractional values, NaN') |
|||
for name, channels in [('empty.mf4', 2), ('time_only.mf4', 1)]: |
|||
with MDF(directory / name) as recording: |
|||
group = recording.groups[0] |
|||
assert group.channel_group.cycles_nr == 0 |
|||
assert len(group.channels) == channels |
|||
assert len(recording.get_master(0)) == 0 |
|||
checks.append(name) |
|||
with MDF(directory / 'wide.mf4') as recording: |
|||
assert len(recording.groups[0].channels) == 4940 |
|||
assert recording.groups[0].channel_group.cycles_nr == 1 |
|||
assert recording.get('DBC_0').samples.tolist() == [123.5] |
|||
assert np.isnan(recording.get('DBC_4938').samples).all() |
|||
np.testing.assert_allclose(recording.get_master(0), [0.25], atol=1e-6) |
|||
checks.append('4939-signal DBC width and missing tail') |
|||
with MDF(directory / 'restart.mf4') as recording: |
|||
assert recording.get('Signal').samples.tolist() == [2.0] |
|||
np.testing.assert_allclose(recording.get_master(0), [0.25], atol=1e-6) |
|||
checks.append('restart resets origin, rejects nonfinite timestamps') |
|||
with (directory / 'paired.csv').open(encoding='utf-8', newline='') as stream: |
|||
rows = list(csv.reader(stream))[7:] |
|||
with MDF(directory / 'paired.mf4') as recording: |
|||
signal = recording.get('Signal') |
|||
assert len(rows) == len(signal.samples) == 2 |
|||
assert [int(row[1]) for row in rows] == [0, 1] |
|||
np.testing.assert_array_equal([float(row[2]) for row in rows], signal.samples) |
|||
assert [row[0][-4:] for row in rows] == ['.125', '.500'] |
|||
np.testing.assert_allclose(signal.timestamps, [0.125, 0.5], atol=1e-6) |
|||
checks.append('CSV/MF4 rows, values and ingress timestamps match') |
|||
shared_origins = [] |
|||
for source, first_offset in [('mdb', -0.010), ('dbc', -0.009), ('formula', -0.010)]: |
|||
with (directory / f'queued_{source}.csv').open(encoding='utf-8', newline='') as stream: |
|||
rows = list(csv.reader(stream))[7:] |
|||
with MDF(directory / f'queued_{source}.mf4') as recording: |
|||
signal = recording.get('Signal') |
|||
origin = recording.start_time.timestamp() |
|||
shared_origins.append(origin) |
|||
assert len(rows) == len(signal.samples) == 3 |
|||
assert [int(row[1]) for row in rows] == [0, 1, 2] |
|||
np.testing.assert_array_equal(signal.samples, [0., 1., 2.]) |
|||
np.testing.assert_array_equal([float(row[2]) for row in rows], signal.samples) |
|||
np.testing.assert_allclose(signal.timestamps, [first_offset, 0., 0.125], rtol=0, atol=1e-6) |
|||
np.testing.assert_allclose(origin, 1790923490.915, rtol=0, atol=1e-6) |
|||
for row, offset in zip(rows, signal.timestamps): |
|||
# UTC/local HH:mm:ss differences do not change the offset within a minute. |
|||
csv_second = float(row[0].rsplit(':', 1)[1]) |
|||
mf4_second = (origin + offset) % 60 |
|||
np.testing.assert_allclose(csv_second, mf4_second, rtol=0, atol=0.001) |
|||
checks.append(f'{source}: queued ingress preserved, CSV/MF4 absolute time and values match') |
|||
assert len(set(shared_origins)) == 1 |
|||
with MDF(directory / 'queued_midnight.mf4') as recording: |
|||
signal = recording.get('Signal') |
|||
origin = recording.start_time.timestamp() |
|||
np.testing.assert_allclose(signal.timestamps, [-0.010], rtol=0, atol=1e-6) |
|||
np.testing.assert_array_equal(signal.samples, [7.]) |
|||
absolute = origin + signal.timestamps[0] |
|||
np.testing.assert_allclose(absolute, 1790985599.995, rtol=0, atol=1e-6) |
|||
assert datetime.fromtimestamp(absolute, recording.start_time.tzinfo).date() < recording.start_time.date() |
|||
checks.append('queued ingress crossing UTC midnight preserves the previous date') |
|||
for check in checks: |
|||
print('PASS:', check) |
|||
print('PASS: ten MF4 files opened by independent reader') |
|||
|
|||
|
|||
if __name__ == '__main__': |
|||
parser = argparse.ArgumentParser(description=__doc__) |
|||
parser.add_argument('directory', type=Path) |
|||
verify(parser.parse_args().directory) |
|||
@ -0,0 +1,38 @@ |
|||
$ErrorActionPreference = 'Stop' |
|||
[void][Reflection.Assembly]::LoadFrom((Join-Path $PSScriptRoot 'DCT_test/bin/Release/DCT_test.exe')) |
|||
function Assert-True($value, $message) { if (!$value) { throw $message } } |
|||
$taskDirectory = Join-Path ([IO.Path]::GetTempPath()) ('dct-log-precision-' + [Guid]::NewGuid().ToString('N')) |
|||
[void][IO.Directory]::CreateDirectory($taskDirectory) |
|||
try { |
|||
$reader = New-Object DCT_test.LogFileReader |
|||
$timestamps = @('23:59:59.999', '23:59:59.999', '23:59:59.999123', '00:00:00.000007') |
|||
$lines = for ($index = 0; $index -lt $timestamps.Count; $index++) { |
|||
$packet = [DCT_test.DctPacket]::Create(0xA3, [byte[]]@($index + 1), 0) |
|||
$hex = ($packet.Encode() | ForEach-Object { $_.ToString('X2') }) -join ' ' |
|||
'[Recv][' + $timestamps[$index] + '] ' + $hex |
|||
} |
|||
$file = Join-Path $taskDirectory 'precision.txt' |
|||
[IO.File]::WriteAllLines($file, [string[]]$lines) |
|||
$result = $reader.Load($file) |
|||
Assert-True $result.Success $result.ErrorMessage |
|||
Assert-True ($result.TotalRecords -eq 4) 'Equal millisecond packets were lost' |
|||
$expectedMilliseconds = @(0.0, 0.0, 0.123, 1.007) |
|||
for ($index = 0; $index -lt 4; $index++) { |
|||
Assert-True ($result.Packets[$index].Payload[0] -eq $index + 1) 'Packet order changed' |
|||
Assert-True ([Math]::Abs($result.Packets[$index].LogElapsedMilliseconds - $expectedMilliseconds[$index]) -lt 0.00001) 'Fraction or midnight rollover lost' |
|||
} |
|||
[IO.File]::WriteAllLines($file, [string[]]@($lines[0], $lines[2].Replace('.999123]', '.99912]'))) |
|||
$invalid = $reader.Load($file) |
|||
Assert-True (-not $invalid.Success -and $invalid.TotalRecords -eq 0) 'Invalid precision accepted or partial packets retained' |
|||
Write-Output 'PASS: 3/6-digit and mixed timestamps, equal-ms packet order, microsecond gaps, midnight rollover, invalid precision rejection' |
|||
} |
|||
finally { |
|||
# Only the unique temporary fixture owned by this check is removed. |
|||
$resolvedTaskDirectory = [IO.Path]::GetFullPath($taskDirectory) |
|||
$resolvedTemporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' |
|||
if (-not $resolvedTaskDirectory.StartsWith($resolvedTemporaryRoot, [StringComparison]::OrdinalIgnoreCase) -or |
|||
[IO.Path]::GetFileName($resolvedTaskDirectory) -notmatch '^dct-log-precision-[a-f0-9]{32}$') { |
|||
throw 'Refusing to remove a fixture outside the expected temporary directory' |
|||
} |
|||
Remove-Item -LiteralPath $resolvedTaskDirectory -Recurse -Force |
|||
} |
|||
Loading…
Reference in new issue