65 changed files with 4395 additions and 405 deletions
@ -0,0 +1,69 @@ |
|||||
|
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) } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,94 @@ |
|||||
|
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) } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
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() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
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() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
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") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.dct.app.data.communication |
||||
|
|
||||
|
/** One selected network path owns the application's TCP session. */ |
||||
|
enum class WifiConnectionMode(val label: String) { |
||||
|
TCP("Wi-Fi TCP"), |
||||
|
DIRECT("Wi-Fi Direct") |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
package com.dct.app.data.preferences |
||||
|
|
||||
|
import android.content.SharedPreferences |
||||
|
|
||||
|
/** Settings survive app exit; access to the dialog is deliberately never persisted. */ |
||||
|
class DeveloperOptionsPreferences(private val preferences: SharedPreferences) { |
||||
|
fun receiveAutoRecoveryEnabled(): Boolean = preferences.getBoolean("receive_auto_recovery", false) |
||||
|
|
||||
|
fun setReceiveAutoRecoveryEnabled(enabled: Boolean) { |
||||
|
preferences.edit().putBoolean("receive_auto_recovery", enabled).apply() |
||||
|
} |
||||
|
|
||||
|
companion object { const val FILE_NAME = "developer_options" } |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
package com.dct.app.presentation.dialog |
||||
|
|
||||
|
import androidx.compose.foundation.layout.* |
||||
|
import androidx.compose.material3.MaterialTheme |
||||
|
import androidx.compose.material3.Tab |
||||
|
import androidx.compose.material3.TabRow |
||||
|
import androidx.compose.material3.Text |
||||
|
import androidx.compose.material3.Surface |
||||
|
import androidx.compose.runtime.Composable |
||||
|
import androidx.compose.runtime.collectAsState |
||||
|
import androidx.compose.runtime.getValue |
||||
|
import androidx.compose.ui.Modifier |
||||
|
import androidx.compose.ui.platform.testTag |
||||
|
import androidx.compose.ui.unit.dp |
||||
|
import androidx.compose.ui.window.Dialog |
||||
|
import com.dct.app.data.communication.WifiConnectionMode |
||||
|
import com.dct.app.presentation.main.MainViewModel |
||||
|
|
||||
|
@Composable |
||||
|
fun WifiConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) { |
||||
|
val mode by viewModel.wifiConnectionMode.collectAsState() |
||||
|
val canChange by viewModel.canChangeWifiConnectionMode.collectAsState() |
||||
|
WifiConnectionDialogFrame(mode, canChange, { viewModel.setWifiConnectionMode(it) }, onDismiss) { |
||||
|
when (mode) { |
||||
|
WifiConnectionMode.TCP -> WifiApConnectionPane(viewModel, onDismiss) |
||||
|
WifiConnectionMode.DIRECT -> WifiDirectDeviceListPane(viewModel, onDismiss) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** Keep the window, size and tabs stable when only the selected network pane changes. */ |
||||
|
@Composable |
||||
|
internal fun WifiConnectionDialogFrame(mode: WifiConnectionMode, canChange: Boolean, |
||||
|
onSelect: (WifiConnectionMode) -> Unit, onDismiss: () -> Unit, content: @Composable () -> Unit) { |
||||
|
Dialog(onDismissRequest = onDismiss) { |
||||
|
Surface(shape = MaterialTheme.shapes.extraLarge, |
||||
|
modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth() |
||||
|
.heightIn(max = 560.dp).fillMaxHeight().testTag("wifi:dialog")) { |
||||
|
Column(Modifier.fillMaxSize()) { |
||||
|
WifiConnectionModeSelector(mode, canChange, onSelect) |
||||
|
Box(Modifier.weight(1f).fillMaxWidth()) { content() } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Composable |
||||
|
internal fun WifiConnectionModeSelector(mode: WifiConnectionMode, enabled: Boolean, |
||||
|
onSelect: (WifiConnectionMode) -> Unit) { |
||||
|
Column { |
||||
|
TabRow(selectedTabIndex = mode.ordinal) { |
||||
|
WifiConnectionMode.entries.forEach { option -> |
||||
|
Tab(selected = mode == option, enabled = enabled || mode == option, |
||||
|
onClick = { onSelect(option) }, text = { Text(option.label) }, |
||||
|
modifier = Modifier.testTag("wifi-mode:${option.name}")) |
||||
|
} |
||||
|
} |
||||
|
if (!enabled) Text("Wi-Fi 해제 후 전환할 수 있습니다.", |
||||
|
Modifier.padding(horizontal = 16.dp, vertical = 8.dp), |
||||
|
style = MaterialTheme.typography.bodySmall) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,40 @@ |
|||||
|
package com.dct.app.presentation.dialog |
||||
|
|
||||
|
import androidx.compose.foundation.layout.Column |
||||
|
import androidx.compose.foundation.text.KeyboardOptions |
||||
|
import androidx.compose.material3.AlertDialog |
||||
|
import androidx.compose.material3.MaterialTheme |
||||
|
import androidx.compose.material3.OutlinedTextField |
||||
|
import androidx.compose.material3.Text |
||||
|
import androidx.compose.material3.TextButton |
||||
|
import androidx.compose.runtime.* |
||||
|
import androidx.compose.ui.Modifier |
||||
|
import androidx.compose.ui.platform.testTag |
||||
|
import androidx.compose.ui.text.input.KeyboardType |
||||
|
import com.dct.app.data.communication.WifiConnectionInput |
||||
|
|
||||
|
@Composable |
||||
|
internal fun WifiDirectPortDialog(port: Int, canEdit: Boolean, |
||||
|
onSave: (Int) -> Boolean, onDismiss: () -> Unit) { |
||||
|
var draft by remember { mutableStateOf(port.toString()) } |
||||
|
var saveRejected by remember { mutableStateOf(false) } |
||||
|
val parsed = WifiConnectionInput(port=draft).portNumber |
||||
|
AlertDialog( |
||||
|
onDismissRequest=onDismiss, |
||||
|
title={ Text("Direct 포트 설정") }, |
||||
|
text={ Column { |
||||
|
Text("장치와 같은 TCP 포트 번호를 입력하세요.", style=MaterialTheme.typography.bodySmall) |
||||
|
OutlinedTextField(value=draft, onValueChange={ draft=it; saveRejected=false }, |
||||
|
label={ Text("TCP 포트") }, singleLine=true, isError=parsed == null, |
||||
|
keyboardOptions=KeyboardOptions(keyboardType=KeyboardType.Number), |
||||
|
modifier=Modifier.testTag("wifi-direct-port")) |
||||
|
if (parsed == null) Text("1~65535 사이의 숫자를 입력하세요.", color=MaterialTheme.colorScheme.error) |
||||
|
if (saveRejected) Text("통신 상태가 변경되어 저장하지 못했습니다. 연결을 해제한 뒤 다시 시도하세요.", |
||||
|
color=MaterialTheme.colorScheme.error) |
||||
|
} }, |
||||
|
confirmButton={ TextButton(enabled=canEdit && parsed != null, onClick={ |
||||
|
parsed?.let { if (onSave(it)) onDismiss() else saveRejected=true } |
||||
|
}) { Text("저장") } }, |
||||
|
dismissButton={ TextButton(onClick=onDismiss) { Text("취소") } } |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package com.dct.app.presentation.main |
||||
|
|
||||
|
/** Owned by the current app session, including rotation and temporary backgrounding. */ |
||||
|
class DeveloperOptionsAccess { |
||||
|
private var unlocked = false |
||||
|
private var taps = 0 |
||||
|
private var lastTapMillis: Long? = null |
||||
|
|
||||
|
fun lock() { |
||||
|
unlocked = false |
||||
|
taps = 0 |
||||
|
lastTapMillis = null |
||||
|
} |
||||
|
|
||||
|
fun onTitleTap(nowMillis: Long): Boolean { |
||||
|
if (unlocked) return true |
||||
|
val previous = lastTapMillis |
||||
|
taps = if (previous != null && nowMillis - previous in 0L..1_000L) taps + 1 else 1 |
||||
|
lastTapMillis = nowMillis |
||||
|
if (taps == 5) unlocked = true |
||||
|
return unlocked |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
package com.dct.app.presentation.monitor.components |
||||
|
|
||||
|
import androidx.compose.foundation.layout.Arrangement |
||||
|
import androidx.compose.foundation.layout.Column |
||||
|
import androidx.compose.foundation.layout.Row |
||||
|
import androidx.compose.foundation.layout.fillMaxWidth |
||||
|
import androidx.compose.material3.AlertDialog |
||||
|
import androidx.compose.material3.MaterialTheme |
||||
|
import androidx.compose.material3.Switch |
||||
|
import androidx.compose.material3.Text |
||||
|
import androidx.compose.material3.TextButton |
||||
|
import androidx.compose.runtime.Composable |
||||
|
import androidx.compose.ui.Alignment |
||||
|
import androidx.compose.ui.Modifier |
||||
|
import androidx.compose.ui.platform.testTag |
||||
|
import androidx.compose.ui.semantics.contentDescription |
||||
|
import androidx.compose.ui.semantics.semantics |
||||
|
import androidx.compose.ui.unit.dp |
||||
|
|
||||
|
@Composable |
||||
|
fun DeveloperOptionsDialog( |
||||
|
receiveAutoRecoveryEnabled: Boolean, |
||||
|
onReceiveAutoRecoveryChange: (Boolean) -> Unit, |
||||
|
onDismiss: () -> Unit |
||||
|
) { |
||||
|
AlertDialog( |
||||
|
onDismissRequest = onDismiss, |
||||
|
title = { Text("개발자 옵션") }, |
||||
|
text = { |
||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { |
||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { |
||||
|
Column(Modifier.weight(1f)) { |
||||
|
Text("수신 자동복구") |
||||
|
Text(if (receiveAutoRecoveryEnabled) "켜짐" else "꺼짐", |
||||
|
style = MaterialTheme.typography.bodySmall, |
||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant) |
||||
|
} |
||||
|
Switch( |
||||
|
checked = receiveAutoRecoveryEnabled, |
||||
|
onCheckedChange = onReceiveAutoRecoveryChange, |
||||
|
modifier = Modifier.testTag("ReceiveAutoRecoverySwitch") |
||||
|
.semantics { contentDescription = "수신 자동복구" } |
||||
|
) |
||||
|
} |
||||
|
Text("수신이 중단되면 연결과 계측 설정을 복구하고 신호 재생을 다시 시작합니다.", |
||||
|
style = MaterialTheme.typography.bodySmall) |
||||
|
} |
||||
|
}, |
||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text("닫기") } } |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
package com.dct.app.presentation.monitor.components |
||||
|
|
||||
|
import android.os.SystemClock |
||||
|
import androidx.compose.material3.Text |
||||
|
import androidx.compose.runtime.* |
||||
|
import androidx.compose.ui.Modifier |
||||
|
import androidx.compose.ui.graphics.Color |
||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner |
||||
|
import androidx.compose.ui.platform.testTag |
||||
|
import androidx.compose.ui.semantics.contentDescription |
||||
|
import androidx.compose.ui.semantics.semantics |
||||
|
import androidx.compose.ui.text.font.FontFamily |
||||
|
import androidx.compose.ui.unit.sp |
||||
|
import androidx.lifecycle.Lifecycle |
||||
|
import androidx.lifecycle.repeatOnLifecycle |
||||
|
import com.dct.app.presentation.main.MainUiState |
||||
|
import kotlinx.coroutines.delay |
||||
|
import kotlinx.coroutines.isActive |
||||
|
|
||||
|
/** Ticks only this label; monotonic time includes screen-off time without changing packet/graph state. */ |
||||
|
@Composable |
||||
|
internal fun MeasurementElapsedTime(ui: MainUiState, color: Color = Color(0xFF535F73), |
||||
|
nowMillis: () -> Long = SystemClock::elapsedRealtime) { |
||||
|
val startedAt = ui.measurementStartedAtElapsedMillis |
||||
|
val lifecycleOwner = LocalLifecycleOwner.current |
||||
|
val readNow by rememberUpdatedState(nowMillis) |
||||
|
var currentMillis by remember(startedAt, ui.isMeasuring) { mutableLongStateOf(nowMillis()) } |
||||
|
LaunchedEffect(lifecycleOwner, startedAt, ui.isMeasuring) { |
||||
|
if (startedAt == null || !ui.isMeasuring) return@LaunchedEffect |
||||
|
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { |
||||
|
while (isActive) { |
||||
|
currentMillis = readNow() |
||||
|
delay(1_000L) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
// Share the existing non-wrapping HH:mm:ss duration formatter; inputs here use a monotonic clock. |
||||
|
val elapsed = if (startedAt == null || !ui.isMeasuring) "00:00:00" |
||||
|
else formatRecordingElapsedTime(startedAt, currentMillis) |
||||
|
Text("재생 $elapsed", fontSize=11.sp, fontFamily=FontFamily.Monospace, color=color, |
||||
|
maxLines=1, softWrap=false, modifier=Modifier.testTag("MeasurementElapsedTime") |
||||
|
.semantics { contentDescription="신호 재생 경과 시간 $elapsed" }) |
||||
|
} |
||||
@ -0,0 +1,212 @@ |
|||||
|
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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,54 @@ |
|||||
|
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) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,89 @@ |
|||||
|
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) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,114 @@ |
|||||
|
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) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,396 @@ |
|||||
|
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() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,239 @@ |
|||||
|
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() }) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,206 @@ |
|||||
|
package com.dct.app.tests |
||||
|
|
||||
|
import android.net.wifi.p2p.WifiP2pDevice |
||||
|
import android.net.wifi.p2p.WifiP2pGroup |
||||
|
import android.net.wifi.p2p.WifiP2pInfo |
||||
|
import com.dct.app.core.common.DctResult |
||||
|
import com.dct.app.data.communication.* |
||||
|
import com.dct.app.domain.repository.DeviceConnectionState |
||||
|
import com.dct.app.domain.repository.PacketRepository |
||||
|
import com.dct.app.presentation.main.* |
||||
|
import io.mockk.* |
||||
|
import kotlinx.coroutines.cancel |
||||
|
import kotlinx.coroutines.flow.MutableStateFlow |
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
import org.junit.jupiter.params.ParameterizedTest |
||||
|
import org.junit.jupiter.params.provider.ValueSource |
||||
|
import java.net.InetAddress |
||||
|
|
||||
|
@Suppress("UNCHECKED_CAST") |
||||
|
class WifiDualModeTest { |
||||
|
private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } |
||||
|
private inner class Fixture : AutoCloseable { |
||||
|
val owner = MainViewModelTest().also { it.setUp() } |
||||
|
val vm = field(owner, "viewModel").get(owner) as MainViewModel |
||||
|
val repo = field(owner, "packetRepository").get(owner) as PacketRepository |
||||
|
val ui = field(vm, "_uiState").get(vm) as MutableStateFlow<MainUiState> |
||||
|
val apState = field(vm, "_wifiApState").get(vm) as MutableStateFlow<WifiApUiState> |
||||
|
val directState = MutableStateFlow(WifiDirectUiState(phase=WifiDirectPhase.IDLE, |
||||
|
enabled=true, permissionGranted=true, synchronizedWithOs=true)) |
||||
|
val direct = mockk<WifiDirectManager>(relaxed=true) |
||||
|
val ap = mockk<WifiApManager>(relaxed=true) |
||||
|
val socket = vm.wifiSocketManager |
||||
|
init { |
||||
|
every { direct.uiState } returns directState |
||||
|
every { ap.uiState } returns apState |
||||
|
field(vm, "registeredWifiApManager").set(vm, ap) |
||||
|
vm.registerWifiDirectManager(direct) |
||||
|
coEvery { repo.connect(any()) } answers { |
||||
|
(field(owner,"repositoryConnected").get(owner) as MutableStateFlow<Boolean>).value=true |
||||
|
(field(owner,"connectionState").get(owner) as MutableStateFlow<DeviceConnectionState>).value=DeviceConnectionState.READY |
||||
|
DctResult.Success(Unit) |
||||
|
} |
||||
|
} |
||||
|
fun connectedDirect(ownerRole: Boolean = false): WifiDirectUiState { |
||||
|
val peer = WifiP2pDevice().apply { deviceName="DCT peer"; deviceAddress="aa:bb:cc:dd:ee:ff" } |
||||
|
val group = mockk<WifiP2pGroup> { |
||||
|
every { getOwner() } returns peer |
||||
|
every { getClientList() } returns listOf(peer) |
||||
|
every { getInterface() } returns "p2p-test" |
||||
|
} |
||||
|
return directState.value.copy(phase=WifiDirectPhase.CONNECTED, |
||||
|
link=WifiDirectLink(WifiP2pInfo().apply { |
||||
|
groupFormed=true; isGroupOwner=ownerRole; groupOwnerAddress=InetAddress.getByName("192.168.77.1") |
||||
|
}, group)) |
||||
|
} |
||||
|
fun apReady() = WifiApUiState(phase=WifiApPhase.CONNECTED, enabled=true, permissionGranted=true, |
||||
|
localAddress="192.168.4.2", selectedSsid="DCT") |
||||
|
override fun close() { vm.scope.cancel(); owner.tearDown() } |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest @ValueSource(booleans=[false,true]) |
||||
|
fun directGroupFormationNeverStartsProtocolUntilExplicitConnect(ownerRole: Boolean) { |
||||
|
Fixture().use { f -> |
||||
|
assertEquals(WifiConnectionMode.TCP, f.vm.wifiConnectionMode.value) |
||||
|
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) |
||||
|
f.directState.value = f.connectedDirect(ownerRole) |
||||
|
assertTrue(f.vm.canConnectCommunication.value) |
||||
|
verify(exactly=0) { f.socket.startDualMode(any(), any(), any(), any(), any(), any()) } |
||||
|
coVerify(exactly=0) { f.repo.connect(any()) } |
||||
|
var ready: ((Boolean,String)->Unit)? = null |
||||
|
every { f.socket.startDualMode(any(), any(), any(), any(), any(), any()) } answers { ready=arg(3) } |
||||
|
f.vm.connectCommunication() |
||||
|
f.vm.connectCommunication() |
||||
|
verify(exactly=1) { f.socket.startDualMode(ownerRole, "192.168.77.1", 5000, any(), any(), any()) } |
||||
|
coVerify(exactly=0) { f.repo.connect(any()) } |
||||
|
ready!!(ownerRole, "192.168.77.1") |
||||
|
coVerify(exactly=1) { f.repo.connect("192.168.77.1") } |
||||
|
coVerify(exactly=1) { f.repo.configureMeasurement(any()) } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test fun modeChangesSelectOnlyTheirOwnRouteAndKeepApEndpoint() { |
||||
|
Fixture().use { f -> |
||||
|
assertTrue(f.vm.setWifiApEndpoint(WifiApEndpoint("192.168.200.9",5000))) |
||||
|
clearMocks(f.socket, answers=false) |
||||
|
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) |
||||
|
verify { f.socket.setWifiApRouteProvider(null) } |
||||
|
verify { f.socket.setRecoveryNetworkProvider(null) } |
||||
|
verify { f.socket.setWifiDirectRouteProvider(isNull(inverse=true)) } |
||||
|
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) |
||||
|
verify { f.socket.setWifiDirectRouteProvider(null) } |
||||
|
verify { f.socket.setWifiApRouteProvider(isNull(inverse=true)) } |
||||
|
assertEquals(WifiApEndpoint("192.168.200.9",5000), f.vm.wifiApEndpoint.value) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest @ValueSource(booleans=[false,true]) |
||||
|
fun configuredDirectPortIsUsedByBothRolesAndLockedDuringCommunication(ownerRole: Boolean) { |
||||
|
Fixture().use { f -> |
||||
|
assertEquals(5000,f.vm.wifiDirectPort.value) |
||||
|
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) |
||||
|
f.directState.value=f.connectedDirect(ownerRole) |
||||
|
for (port in listOf(0,65536,-1)) assertFalse(f.vm.setWifiDirectPort(port)) |
||||
|
assertTrue(f.vm.setWifiDirectPort(6000)) |
||||
|
assertEquals(WifiApEndpoint(),f.vm.wifiApEndpoint.value) |
||||
|
f.vm.connectCommunication() |
||||
|
verify(exactly=1) { f.socket.startDualMode(ownerRole,"192.168.77.1",6000,any(),any(),any()) } |
||||
|
assertFalse(f.vm.canEditWifiDirectPort.value) |
||||
|
assertFalse(f.vm.setWifiDirectPort(5000)) |
||||
|
f.vm.disconnectCommunication() |
||||
|
assertTrue(f.vm.setWifiDirectPort(5001)) |
||||
|
assertEquals(5001,f.vm.wifiDirectPort.value) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test fun connectedMeasuringAndRecordingCannotChangeDirectPort() { |
||||
|
Fixture().use { f -> |
||||
|
for (ui in listOf(MainUiState(isConnected=true),MainUiState(isMeasuring=true), |
||||
|
MainUiState(recordingState=MeasurementRecordingState.RECORDING))) { |
||||
|
f.ui.value=ui |
||||
|
assertFalse(f.vm.setWifiDirectPort(6000)) |
||||
|
assertEquals(5000,f.vm.wifiDirectPort.value) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test fun disconnectKeepsTheDirectGroupAndWifiReleaseRemovesOnlyThatGroup() { |
||||
|
Fixture().use { f -> |
||||
|
f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT) |
||||
|
f.directState.value=f.connectedDirect() |
||||
|
f.ui.value=f.ui.value.copy(isConnected=true, communicationType=CommunicationType.WIFI) |
||||
|
f.vm.disconnectCommunication() |
||||
|
verify(exactly=0) { f.direct.disconnect() } |
||||
|
verify(exactly=0) { f.ap.disconnect() } |
||||
|
coVerify(exactly=1) { f.repo.disconnect() } |
||||
|
assertTrue(f.vm.canConnectCommunication.value) |
||||
|
assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) |
||||
|
f.vm.disconnectCommunication(releaseWifiAp=true) |
||||
|
verify(exactly=1) { f.direct.disconnect() } |
||||
|
verify(exactly=0) { f.ap.disconnect() } |
||||
|
f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE) |
||||
|
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test fun inactiveDirectGroupLossNeverClosesApCommunication() { |
||||
|
Fixture().use { f -> |
||||
|
f.apState.value=f.apReady() |
||||
|
f.directState.value=f.connectedDirect() |
||||
|
f.ui.value=f.ui.value.copy(isConnected=true,communicationType=CommunicationType.WIFI) |
||||
|
f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE) |
||||
|
verify(exactly=0) { f.socket.invalidateConnection() } |
||||
|
coVerify(exactly=0) { f.repo.disconnect() } |
||||
|
assertTrue(f.vm.uiState.value.isConnected) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test fun activeDirectGroupLossClosesItsTransportWithoutStartingAnother() { |
||||
|
Fixture().use { f -> |
||||
|
f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT) |
||||
|
f.directState.value=f.connectedDirect() |
||||
|
f.ui.value=f.ui.value.copy(isConnected=true,communicationType=CommunicationType.WIFI) |
||||
|
f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE) |
||||
|
verify(exactly=1) { f.socket.invalidateConnection() } |
||||
|
coVerify(exactly=1) { f.repo.disconnect() } |
||||
|
verify(exactly=0) { f.socket.startDualMode(any(),any(),any(),any(),any(),any()) } |
||||
|
assertFalse(f.vm.uiState.value.isConnected) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test fun apModeAndDirectModeUseIndependentReadiness() { |
||||
|
Fixture().use { f -> |
||||
|
f.apState.value=f.apReady() |
||||
|
assertTrue(f.vm.canConnectCommunication.value) |
||||
|
assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) |
||||
|
f.apState.value=WifiApUiState() |
||||
|
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) |
||||
|
assertFalse(f.vm.canConnectCommunication.value) |
||||
|
f.apState.value=f.apReady() |
||||
|
assertFalse(f.vm.canConnectCommunication.value) |
||||
|
f.directState.value=f.connectedDirect() |
||||
|
assertTrue(f.vm.canConnectCommunication.value) |
||||
|
f.directState.value=f.directState.value.copy(permissionGranted=false) |
||||
|
assertFalse(f.vm.canConnectCommunication.value) |
||||
|
f.vm.connectCommunication() |
||||
|
verify(exactly=0) { f.socket.startDualMode(any(),any(),any(),any(),any(),any()) } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test fun measurementRecordingAndNegotiationBlockModeChanges() { |
||||
|
Fixture().use { f -> |
||||
|
for (state in listOf(MainUiState(isMeasuring=true), MainUiState(isCommunicationConnecting=true), |
||||
|
MainUiState(recordingState=MeasurementRecordingState.RECORDING),MainUiState(isConnected=true))) { |
||||
|
f.ui.value=state |
||||
|
assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) |
||||
|
} |
||||
|
f.ui.value=MainUiState() |
||||
|
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) |
||||
|
f.directState.value=f.directState.value.copy(phase=WifiDirectPhase.CONNECTING) |
||||
|
assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) |
||||
|
f.vm.disconnectCommunication(releaseWifiAp=true) |
||||
|
verify(exactly=1) { f.direct.cancelConnect() } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
package com.dct.app.tests |
||||
|
|
||||
|
import com.dct.app.data.communication.WifiSocketManager |
||||
|
import com.dct.app.data.protocol.DctPacketCodec |
||||
|
import com.dct.app.data.protocol.DctProtocolRequests |
||||
|
import kotlinx.coroutines.* |
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
import java.net.ServerSocket |
||||
|
import java.net.Socket |
||||
|
|
||||
|
class WifiSocketRecoveryTest { |
||||
|
@Test fun recoveryReopensTheSameEndpointAndOldOwnerCannotReplaceANewManualSession() = runBlocking { |
||||
|
val manager = WifiSocketManager() |
||||
|
ServerSocket(0).use { server -> |
||||
|
server.soTimeout = 3000 |
||||
|
val peers = mutableListOf<Socket>() |
||||
|
suspend fun connect() { |
||||
|
val connected = CompletableDeferred<Unit>() |
||||
|
manager.connect("127.0.0.1", server.localPort, |
||||
|
onSuccess = { connected.complete(Unit) }, |
||||
|
onFailure = { connected.completeExceptionally(IllegalStateException(it)) }) |
||||
|
peers += withContext(Dispatchers.IO) { server.accept() }.apply { soTimeout = 3000 } |
||||
|
withTimeout(5000) { connected.await() } |
||||
|
} |
||||
|
try { |
||||
|
connect() |
||||
|
val owner = manager.recoveryGeneration |
||||
|
val session = manager.currentSessionId |
||||
|
val endpoint = manager.recoveryEndpoint!! |
||||
|
manager.pauseForNetworkRecovery() |
||||
|
assertFalse(manager.isConnected.value) |
||||
|
assertEquals(owner, manager.recoveryGeneration) |
||||
|
assertEquals(endpoint, manager.recoveryEndpoint) |
||||
|
val recovery = async { manager.reconnectForRecovery(owner, 0, endpoint) } |
||||
|
peers += withContext(Dispatchers.IO) { server.accept() }.apply { soTimeout = 3000 } |
||||
|
assertTrue(withTimeout(5000) { recovery.await() }) |
||||
|
assertTrue(manager.currentSessionId > session) |
||||
|
assertEquals(owner, manager.recoveryGeneration) |
||||
|
val frame = DctPacketCodec().encode(DctProtocolRequests.possibleToConnect()) |
||||
|
assertTrue(manager.sendCommand(DctProtocolRequests.possibleToConnect())) |
||||
|
assertArrayEquals(frame, withContext(Dispatchers.IO) { peers.last().getInputStream().readNBytes(frame.size) }) |
||||
|
connect() // A manual connection takes ownership, even at the same address. |
||||
|
val newest = manager.currentSessionId |
||||
|
assertFalse(manager.reconnectForRecovery(owner, newest, endpoint)) |
||||
|
assertEquals(newest, manager.currentSessionId) |
||||
|
assertTrue(manager.isConnected.value) |
||||
|
assertTrue(manager.sendCommand(DctProtocolRequests.possibleToConnect())) |
||||
|
assertArrayEquals(frame, withContext(Dispatchers.IO) { peers.last().getInputStream().readNBytes(frame.size) }) |
||||
|
} finally { manager.close(); peers.forEach { it.close() } } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,243 @@ |
|||||
|
using System; |
||||
|
using System.Diagnostics; |
||||
|
using System.Globalization; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using System.Net; |
||||
|
using System.Net.NetworkInformation; |
||||
|
using System.Net.Sockets; |
||||
|
using System.Runtime.InteropServices; |
||||
|
using System.Security.Cryptography; |
||||
|
using System.Text; |
||||
|
|
||||
|
namespace DCT_test |
||||
|
{ |
||||
|
public sealed class NetworkFirewallSettings |
||||
|
{ |
||||
|
public string ProgramPath { get; private set; } |
||||
|
public string LocalAddresses { get; private set; } |
||||
|
public int Port { get; private set; } |
||||
|
public string Mode { get; private set; } |
||||
|
public string RuleName { get; private set; } |
||||
|
|
||||
|
public NetworkFirewallSettings(string programPath, int port, string mode, string addresses) |
||||
|
{ |
||||
|
if (port < 1 || port > 65535) throw new ArgumentException("포트는 1~65535여야 합니다."); |
||||
|
if (mode != "direct" && mode != "ap") throw new ArgumentException("잘못된 연결 모드입니다."); |
||||
|
if (string.IsNullOrWhiteSpace(programPath) || !Path.IsPathRooted(programPath) || |
||||
|
!string.Equals(Path.GetExtension(programPath), ".exe", StringComparison.OrdinalIgnoreCase)) |
||||
|
throw new ArgumentException("실행 파일의 전체 경로가 필요합니다."); |
||||
|
var parsed = (addresses ?? "").Split(',').Select(value => |
||||
|
{ |
||||
|
IPAddress address; |
||||
|
if (!IPAddress.TryParse(value, out address) || address.AddressFamily != AddressFamily.InterNetwork) |
||||
|
throw new ArgumentException("허용할 PC IPv4 주소를 먼저 확인하세요."); |
||||
|
var bytes = address.GetAddressBytes(); |
||||
|
if (bytes[0] == 0 || bytes[0] == 127 || bytes[0] >= 224 || |
||||
|
!string.Equals(value, address.ToString(), StringComparison.Ordinal)) |
||||
|
throw new ArgumentException("명시적인 PC IPv4 주소만 허용할 수 있습니다."); |
||||
|
return address.ToString(); |
||||
|
}).Distinct().OrderBy(value => value, StringComparer.Ordinal).ToArray(); |
||||
|
ProgramPath = Path.GetFullPath(programPath); |
||||
|
LocalAddresses = string.Join(",", parsed); |
||||
|
Port = port; |
||||
|
Mode = mode; |
||||
|
using (var hash = SHA256.Create()) |
||||
|
{ |
||||
|
string key = BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(ProgramPath.ToUpperInvariant()))) |
||||
|
.Replace("-", "").Substring(0, 16); |
||||
|
RuleName = "DCT-Test-TCP-" + key + "-" + mode + "-" + port.ToString(CultureInfo.InvariantCulture); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public string HelperArguments => "--allow-network " + Port.ToString(CultureInfo.InvariantCulture) + " " + Mode + " " + LocalAddresses; |
||||
|
} |
||||
|
|
||||
|
public static class NetworkSupport |
||||
|
{ |
||||
|
public const string FirewallGroup = "DCT Test Network Access"; |
||||
|
public static string ExecutablePath => Process.GetCurrentProcess().MainModule.FileName; |
||||
|
|
||||
|
// Shared with the verifier: no COM or OS mutations occur in this method.
|
||||
|
public static void ConfigureRule(object target, NetworkFirewallSettings settings) |
||||
|
{ |
||||
|
dynamic rule = target; |
||||
|
rule.Name = settings.RuleName; |
||||
|
rule.Description = "DCT 테스트 서버: 현재 실행 파일, 지정 PC IPv4와 TCP 포트, 로컬 서브넷만 허용"; |
||||
|
rule.Grouping = FirewallGroup; |
||||
|
rule.ApplicationName = settings.ProgramPath; |
||||
|
rule.Protocol = 6; // NET_FW_IP_PROTOCOL_TCP; must precede LocalPorts.
|
||||
|
rule.LocalPorts = settings.Port.ToString(CultureInfo.InvariantCulture); |
||||
|
rule.LocalAddresses = settings.LocalAddresses; |
||||
|
rule.RemoteAddresses = "LocalSubnet"; |
||||
|
rule.Direction = 1; // inbound
|
||||
|
rule.Action = 1; // allow
|
||||
|
rule.Profiles = 7; // domain/private/public; IP and application remain scoped.
|
||||
|
rule.EdgeTraversal = false; |
||||
|
rule.Enabled = true; |
||||
|
} |
||||
|
|
||||
|
public static void AllowFirewall(NetworkFirewallSettings settings) |
||||
|
{ |
||||
|
dynamic policy = null, rule = null; |
||||
|
try |
||||
|
{ |
||||
|
policy = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FwPolicy2", true)); |
||||
|
if ((int)policy.LocalPolicyModifyState != 0) |
||||
|
throw new InvalidOperationException("현재 방화벽 정책에서 로컬 규칙을 적용할 수 없습니다. 관리자 정책을 확인하세요."); |
||||
|
foreach (dynamic existing in policy.Rules) |
||||
|
{ |
||||
|
if ((string)existing.Name == settings.RuleName) |
||||
|
{ |
||||
|
if (!string.Equals((string)existing.ApplicationName, settings.ProgramPath, StringComparison.OrdinalIgnoreCase) || |
||||
|
(string)existing.Grouping != FirewallGroup || (int)existing.Action != 1 || |
||||
|
(int)existing.Direction != 1 || (int)existing.Protocol != 6) |
||||
|
{ |
||||
|
Marshal.FinalReleaseComObject((object)existing); |
||||
|
throw new InvalidOperationException("같은 이름의 다른 방화벽 규칙이 있습니다. 연결 진단을 확인하세요."); |
||||
|
} |
||||
|
rule = existing; |
||||
|
break; |
||||
|
} |
||||
|
Marshal.FinalReleaseComObject((object)existing); |
||||
|
} |
||||
|
bool isNew = rule == null; |
||||
|
if (isNew) rule = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FWRule", true)); |
||||
|
ConfigureRule((object)rule, settings); |
||||
|
// Same executable/mode/port updates its own rule after DHCP changes.
|
||||
|
// Explicit block rules and all other applications' rules remain untouched.
|
||||
|
if (isNew) policy.Rules.Add(rule); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
if (rule != null) Marshal.FinalReleaseComObject((object)rule); |
||||
|
if (policy != null) Marshal.FinalReleaseComObject((object)policy); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public static string BuildReport(string mode, int port, string selectedAddress, |
||||
|
bool serverRunning, string[] directAddresses, string[] clients) |
||||
|
{ |
||||
|
var report = new StringBuilder(); |
||||
|
report.AppendLine("DCT 연결 진단 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); |
||||
|
report.AppendLine("PC 이름: " + Environment.MachineName); |
||||
|
report.AppendLine("실행 파일: " + ExecutablePath); |
||||
|
report.AppendLine("모드: " + mode + " / 포트: " + port); |
||||
|
report.AppendLine("이 프로그램 서버: " + (serverRunning ? "실행 중" : "중지 — 서버 시작을 먼저 누르세요.")); |
||||
|
report.AppendLine("연결된 TCP 클라이언트: " + (clients.Length == 0 ? "없음" : string.Join(", ", clients))); |
||||
|
if (mode == "direct") |
||||
|
report.AppendLine("Direct 데이터 리스너: " + (directAddresses.Length == 0 |
||||
|
? "아직 없음 — 서버 시작 후 태블릿에서 이 PC에 Direct 연결하세요." |
||||
|
: string.Join(", ", directAddresses.Select(ip => ip + ":" + port)))); |
||||
|
else report.AppendLine("앱에 입력할 주소: " + selectedAddress + ":" + port); |
||||
|
report.AppendLine(); |
||||
|
report.AppendLine("[현재 어댑터 IPv4]"); |
||||
|
try |
||||
|
{ |
||||
|
bool selectedFound = false; |
||||
|
foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces()) |
||||
|
{ |
||||
|
foreach (var address in adapter.GetIPProperties().UnicastAddresses |
||||
|
.Where(value => value.Address.AddressFamily == AddressFamily.InterNetwork)) |
||||
|
{ |
||||
|
report.AppendLine(adapter.Name + " / " + adapter.OperationalStatus + " / " + address.Address + |
||||
|
" / 마스크 " + address.IPv4Mask + " / " + adapter.Description); |
||||
|
if (adapter.OperationalStatus == OperationalStatus.Up && address.Address.ToString() == selectedAddress) |
||||
|
selectedFound = true; |
||||
|
} |
||||
|
} |
||||
|
if (mode != "direct" && !selectedFound) |
||||
|
report.AppendLine("주의: 선택 IP가 현재 활성 어댑터에 없습니다. 서버를 중지하고 현재 Wi-Fi IP를 다시 선택하세요."); |
||||
|
} |
||||
|
catch (Exception ex) { report.AppendLine("IP 조회 실패: " + ex.Message); } |
||||
|
report.AppendLine(); |
||||
|
report.AppendLine("[Windows TCP 수신 포트]"); |
||||
|
try |
||||
|
{ |
||||
|
var listeners = IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners().Where(item => item.Port == port).ToArray(); |
||||
|
report.AppendLine(listeners.Length == 0 ? "조회된 수신 포트 없음" : string.Join(", ", listeners.Select(item => item.ToString()))); |
||||
|
report.AppendLine("전체 프로세스의 조회 결과입니다. Direct의 별도 네트워크 구획 리스너는 위 Direct 데이터 리스너 표시도 확인하세요."); |
||||
|
} |
||||
|
catch (Exception ex) { report.AppendLine("수신 포트 조회 실패: " + ex.Message); } |
||||
|
report.AppendLine(); |
||||
|
AppendFirewallReport(report, port); |
||||
|
report.AppendLine(); |
||||
|
report.AppendLine("[현재 Wi-Fi AP]"); |
||||
|
report.AppendLine(ReadWifiStatus()); |
||||
|
report.AppendLine("[연결이 계속 실패할 때]"); |
||||
|
report.AppendLine("• 태블릿과 PC의 AP/서브넷 및 입력 IP·포트를 맞추세요. Direct에서는 위 Direct 주소를 사용합니다."); |
||||
|
report.AppendLine("• 서버 실행 후 '통신 허용 (관리자)'을 누르고 태블릿에서 다시 연결하세요."); |
||||
|
report.AppendLine("• 허용 규칙이 있어도 명시적 차단·관리자 정책·보안 프로그램은 별도 확인이 필요합니다."); |
||||
|
report.AppendLine("• 상대 IP의 MAC 주소도 확인되지 않는 ARP 실패는 포트 허용만으로 해결되지 않습니다. AP 격리/게스트망/다른 어댑터 연결을 확인하세요."); |
||||
|
report.AppendLine("로컬 수신/허용 규칙은 상대 태블릿의 실제 접속 성공을 보장하지 않습니다."); |
||||
|
return report.ToString(); |
||||
|
} |
||||
|
|
||||
|
private static void AppendFirewallReport(StringBuilder report, int port) |
||||
|
{ |
||||
|
report.AppendLine("[Windows 방화벽]"); |
||||
|
dynamic policy = null; |
||||
|
try |
||||
|
{ |
||||
|
policy = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FwPolicy2", true)); |
||||
|
int profiles = policy.CurrentProfileTypes; |
||||
|
report.AppendLine("활성 프로필: " + profiles + " (1=도메인, 2=개인, 4=공용)"); |
||||
|
report.AppendLine("로컬 정책 수정 상태: " + policy.LocalPolicyModifyState + " (0=적용 가능)"); |
||||
|
int ownRules = 0; |
||||
|
foreach (dynamic rule in policy.Rules) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (!string.Equals((string)rule.ApplicationName, ExecutablePath, StringComparison.OrdinalIgnoreCase)) continue; |
||||
|
if (!(bool)rule.Enabled || (int)rule.Direction != 1) continue; |
||||
|
if ((int)rule.Action == 0 && ((int)rule.Profiles & profiles) != 0) |
||||
|
report.AppendLine("주의: 이 실행 파일의 명시적 수신 차단 규칙: " + rule.Name); |
||||
|
if ((string)rule.Grouping == FirewallGroup && (int)rule.Protocol == 6 && |
||||
|
(string)rule.LocalPorts == port.ToString(CultureInfo.InvariantCulture) && (int)rule.Action == 1) |
||||
|
{ |
||||
|
ownRules++; |
||||
|
report.AppendLine("프로그램 허용 규칙: " + rule.Name + " / IP " + rule.LocalAddresses + " / 원격 " + rule.RemoteAddresses); |
||||
|
} |
||||
|
} |
||||
|
finally { if (Marshal.IsComObject(rule)) Marshal.FinalReleaseComObject((object)rule); } |
||||
|
} |
||||
|
if (ownRules == 0) report.AppendLine("이 실행 파일/포트의 프로그램 전용 허용 규칙 없음 (다른 허용 정책의 유무와는 별개)"); |
||||
|
} |
||||
|
catch (Exception ex) { report.AppendLine("방화벽 조회 실패: " + ex.Message); } |
||||
|
finally { if (policy != null) Marshal.FinalReleaseComObject((object)policy); } |
||||
|
} |
||||
|
|
||||
|
private static string ReadWifiStatus() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var info = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "netsh.exe"), "wlan show interfaces") |
||||
|
{ |
||||
|
UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true |
||||
|
}; |
||||
|
using (var process = Process.Start(info)) |
||||
|
using (var bytes = new MemoryStream()) |
||||
|
{ |
||||
|
var output = process.StandardOutput.BaseStream.CopyToAsync(bytes); |
||||
|
if (!process.WaitForExit(5000)) |
||||
|
{ |
||||
|
process.Kill(); |
||||
|
return "Wi-Fi AP 조회 시간 초과"; |
||||
|
} |
||||
|
if (!output.Wait(1000)) return "Wi-Fi AP 조회 출력 시간 초과"; |
||||
|
return "종료 코드 " + process.ExitCode + Environment.NewLine + DecodeConsoleOutput(bytes.ToArray()); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) { return "Wi-Fi AP 조회 실패: " + ex.Message; } |
||||
|
} |
||||
|
|
||||
|
public static string DecodeConsoleOutput(byte[] bytes) |
||||
|
{ |
||||
|
try { return new UTF8Encoding(false, true).GetString(bytes); } |
||||
|
catch (DecoderFallbackException) |
||||
|
{ |
||||
|
return Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.OEMCodePage).GetString(bytes); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
param([string]$BuildFolder = 'NetworkTools') |
||||
|
|
||||
|
if ($PSVersionTable.PSEdition -ne 'Desktop') { |
||||
|
& "$env:SystemRoot/System32/WindowsPowerShell/v1.0/powershell.exe" -NoProfile -STA -ExecutionPolicy Bypass -File $PSCommandPath -BuildFolder $BuildFolder |
||||
|
exit $LASTEXITCODE |
||||
|
} |
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
Add-Type -AssemblyName PresentationFramework |
||||
|
$networkAssembly = [Reflection.Assembly]::LoadFrom((Join-Path $PSScriptRoot "DCT_test/bin/$BuildFolder/DCT_test.exe")) |
||||
|
function Assert-Network($condition, $message) { if (-not $condition) { throw $message } } |
||||
|
Add-Type @' |
||||
|
public class FirewallRuleProbe { |
||||
|
public string Name {get;set;} |
||||
|
public string Description {get;set;} |
||||
|
public string Grouping {get;set;} |
||||
|
public string ApplicationName {get;set;} |
||||
|
public int Protocol {get;set;} |
||||
|
public string LocalPorts {get;set;} |
||||
|
public string LocalAddresses {get;set;} |
||||
|
public string RemoteAddresses {get;set;} |
||||
|
public int Direction {get;set;} |
||||
|
public int Action {get;set;} |
||||
|
public int Profiles {get;set;} |
||||
|
public bool EdgeTraversal {get;set;} |
||||
|
public bool Enabled {get;set;} |
||||
|
} |
||||
|
'@ |
||||
|
$exe = $networkAssembly.Location |
||||
|
$settings = [DCT_test.NetworkFirewallSettings]::new($exe, 5000, 'ap', '192.168.200.5,192.168.200.5') |
||||
|
$rule = New-Object FirewallRuleProbe |
||||
|
[DCT_test.NetworkSupport]::ConfigureRule($rule, $settings) |
||||
|
Assert-Network ($rule.ApplicationName -eq $exe -and $rule.Protocol -eq 6 -and $rule.LocalPorts -eq '5000') 'Rule must restrict executable and TCP port' |
||||
|
Assert-Network ($rule.LocalAddresses -eq '192.168.200.5' -and $rule.RemoteAddresses -eq 'LocalSubnet') 'Rule must restrict local IPv4 and remote subnet' |
||||
|
Assert-Network ($rule.Direction -eq 1 -and $rule.Action -eq 1 -and $rule.Enabled -and $rule.Profiles -eq 7 -and -not $rule.EdgeTraversal) 'Inbound allow policy contract' |
||||
|
Assert-Network ($settings.HelperArguments -eq '--allow-network 5000 ap 192.168.200.5') 'Validated UAC helper arguments' |
||||
|
$changedIp = [DCT_test.NetworkFirewallSettings]::new($exe,5000,'ap','192.168.200.6') |
||||
|
$direct = [DCT_test.NetworkFirewallSettings]::new($exe,5000,'direct','192.168.137.1') |
||||
|
Assert-Network ($changedIp.RuleName -eq $settings.RuleName -and $direct.RuleName -ne $settings.RuleName) 'DHCP updates same rule; Direct/AP remain separate' |
||||
|
foreach ($badAddress in @('*','0.0.0.0','127.0.0.1','224.0.0.1','::1','192.168.200.0/24','192.168.200.5;whoami','192.168.1','')) { |
||||
|
$rejected = $false |
||||
|
try { [void][DCT_test.NetworkFirewallSettings]::new($exe,5000,'ap',$badAddress) } catch { $rejected = $true } |
||||
|
Assert-Network $rejected ('Unsafe address accepted: ' + $badAddress) |
||||
|
} |
||||
|
foreach ($badPort in @(0,65536)) { |
||||
|
$rejected = $false |
||||
|
try { [void][DCT_test.NetworkFirewallSettings]::new($exe,$badPort,'ap','192.168.200.5') } catch { $rejected = $true } |
||||
|
Assert-Network $rejected 'Invalid port accepted' |
||||
|
} |
||||
|
$report = [DCT_test.NetworkSupport]::BuildReport('ap',5000,'203.0.113.111',$false,[string[]]@(),[string[]]@()) |
||||
|
Assert-Network ($report.Contains('203.0.113.111:5000') -and $report.Contains('ARP') -and $report.Contains('netsh') -eq $false) 'Report must identify selected endpoint and ARP limitation without requiring terminal commands' |
||||
|
Assert-Network ($report.Contains('Windows TCP') -and $report.Contains('Wi-Fi AP')) 'Report must include listener and AP inventory' |
||||
|
Assert-Network ($report.Contains([DCT_test.NetworkSupport]::ExecutablePath)) 'Report must use actual executable path' |
||||
|
$korean = [string][char]0xC5F0 + [string][char]0xACB0 |
||||
|
Assert-Network ([DCT_test.NetworkSupport]::DecodeConsoleOutput([Text.Encoding]::UTF8.GetBytes($korean)) -eq $korean) 'Korean UTF-8 console text must remain readable' |
||||
|
$oem = [Text.Encoding]::GetEncoding([Globalization.CultureInfo]::CurrentCulture.TextInfo.OEMCodePage) |
||||
|
if ($oem.CodePage -eq 949) { |
||||
|
Assert-Network ([DCT_test.NetworkSupport]::DecodeConsoleOutput($oem.GetBytes($korean)) -eq $korean) 'Legacy Korean console output must remain readable' |
||||
|
} |
||||
|
Write-Output $report |
||||
|
$window = New-Object DCT_test.MainWindow |
||||
|
try { |
||||
|
Assert-Network ($null -ne $window.FindName('BtnNetworkDiagnostics') -and $null -ne $window.FindName('BtnAllowNetwork')) 'Network action buttons missing' |
||||
|
$window.GetType().GetMethod('BtnAllowNetwork_Click',[Reflection.BindingFlags]'Instance,NonPublic').Invoke($window,@($null,$null)) | Out-Null |
||||
|
Assert-Network ($window.FindName('BtnAllowNetwork').IsEnabled -and $window.FindName('BtnToggleServer').IsEnabled) 'Stopped server must not launch or lock the admin helper' |
||||
|
} finally { $window.Close() } |
||||
|
Write-Output 'PASS: firewall plan scope, validation, helper arguments, DHCP/mode rule identity, real read-only network/firewall/AP report, stopped-server guard (no firewall changes or UAC)' |
||||
@ -0,0 +1,148 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.IO; |
||||
|
using System.Net; |
||||
|
using System.Net.Sockets; |
||||
|
using System.Reflection; |
||||
|
using System.Runtime.InteropServices.WindowsRuntime; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Windows.Networking; |
||||
|
using Windows.Networking.Sockets; |
||||
|
|
||||
|
namespace DCT_test |
||||
|
{ |
||||
|
// Uses the production WinRT bridge callback and copy loops on loopback.
|
||||
|
// Radio discovery, WPS and a physical tablet are deliberately outside this test.
|
||||
|
internal static class VerifyWifiDirect |
||||
|
{ |
||||
|
private sealed class Bridge : IDisposable |
||||
|
{ |
||||
|
private readonly StreamSocketListener listener = new StreamSocketListener(); |
||||
|
private readonly WifiDirectHost host; |
||||
|
private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); |
||||
|
private int receivedConnections; |
||||
|
|
||||
|
public Bridge(IPEndPoint server) |
||||
|
{ |
||||
|
host = new WifiDirectHost(server.Port); |
||||
|
typeof(WifiDirectHost).GetField("_cts", BindingFlags.Instance | BindingFlags.NonPublic) |
||||
|
.SetValue(host, cancellation); |
||||
|
var callback = typeof(WifiDirectHost).GetMethod("SocketListener_ConnectionReceived", |
||||
|
BindingFlags.Instance | BindingFlags.NonPublic); |
||||
|
listener.Control.NoDelay = true; |
||||
|
listener.ConnectionReceived += (sender, args) => |
||||
|
{ |
||||
|
Interlocked.Increment(ref receivedConnections); |
||||
|
callback.Invoke(host, new object[] { sender, args }); |
||||
|
}; |
||||
|
listener.BindEndpointAsync(new HostName("127.0.0.1"), "0") |
||||
|
.AsTask().GetAwaiter().GetResult(); |
||||
|
} |
||||
|
|
||||
|
public TcpClient Connect() |
||||
|
{ |
||||
|
var client = new TcpClient { NoDelay = true }; |
||||
|
client.Connect(IPAddress.Loopback, int.Parse(listener.Information.LocalPort)); |
||||
|
if (!SpinWait.SpinUntil(() => Volatile.Read(ref receivedConnections) > 0, 2000)) |
||||
|
{ |
||||
|
client.Dispose(); |
||||
|
throw new Exception("WinRT listener did not accept the test client"); |
||||
|
} |
||||
|
return client; |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
host.Dispose(); |
||||
|
listener.Dispose(); |
||||
|
// The async bridge can still observe cancellation while disposing its I/O.
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static int Main() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
TransportRegression.RunCurrentAppCompatibility(); |
||||
|
foreach (bool fragmented in new[] { false, true }) |
||||
|
{ |
||||
|
var bridges = new List<Bridge>(); |
||||
|
try |
||||
|
{ |
||||
|
TransportRegression.RunCurrentAppCompatibility(endpoint => |
||||
|
{ |
||||
|
var bridge = new Bridge(endpoint); |
||||
|
bridges.Add(bridge); |
||||
|
return bridge.Connect(); |
||||
|
}, fragmented); |
||||
|
Console.WriteLine("PASS: production Wi-Fi Direct WinRT bridge protocol parity; fragmented=" + fragmented); |
||||
|
} |
||||
|
finally { foreach (var bridge in bridges) bridge.Dispose(); } |
||||
|
} |
||||
|
VerifyBridgeBytes(); |
||||
|
return 0; |
||||
|
} |
||||
|
catch (Exception ex) { Console.Error.WriteLine(ex); return 1; } |
||||
|
} |
||||
|
|
||||
|
private static void VerifyBridgeBytes() |
||||
|
{ |
||||
|
var server = new TcpListener(IPAddress.Loopback, 0); |
||||
|
server.Start(); |
||||
|
try |
||||
|
{ |
||||
|
using (var bridge = new Bridge((IPEndPoint)server.LocalEndpoint)) |
||||
|
using (var peer = bridge.Connect()) |
||||
|
using (var target = server.AcceptTcpClient()) |
||||
|
{ |
||||
|
var peerStream = peer.GetStream(); |
||||
|
var targetStream = target.GetStream(); |
||||
|
peerStream.ReadTimeout = targetStream.ReadTimeout = 5000; |
||||
|
peerStream.WriteTimeout = targetStream.WriteTimeout = 5000; |
||||
|
byte[] payload = new byte[2 * 1024 * 1024]; |
||||
|
for (int i = 0; i < payload.Length; i++) payload[i] = (byte)(i * 37 + i / 251); |
||||
|
var up = Task.Run(() => peerStream.Write(payload, 0, payload.Length)); |
||||
|
CheckBytes(targetStream, payload); |
||||
|
up.GetAwaiter().GetResult(); |
||||
|
var down = Task.Run(() => targetStream.Write(payload, 0, payload.Length)); |
||||
|
Thread.Sleep(1800); // A temporary paused receiver must keep byte order and the connection.
|
||||
|
CheckBytes(peerStream, payload); |
||||
|
down.GetAwaiter().GetResult(); |
||||
|
targetStream.WriteByte(0x85); |
||||
|
if (peerStream.ReadByte() != 0x85) throw new Exception("Control byte after receiver pause"); |
||||
|
target.Close(); |
||||
|
try |
||||
|
{ |
||||
|
if (peerStream.ReadByte() != -1) throw new Exception("Bridge must propagate server close"); |
||||
|
} |
||||
|
catch (IOException ex) |
||||
|
{ |
||||
|
// Disposing a WinRT socket with its upstream read pending can
|
||||
|
// surface a TCP reset. Only accept that after our explicit close,
|
||||
|
// after every payload byte and the following control were checked.
|
||||
|
var socketError = ex.InnerException as SocketException; |
||||
|
if (socketError == null || (socketError.SocketErrorCode != System.Net.Sockets.SocketError.ConnectionReset && |
||||
|
socketError.SocketErrorCode != System.Net.Sockets.SocketError.ConnectionAborted)) throw; |
||||
|
} |
||||
|
Console.WriteLine("PASS: production WinRT bridge 2MiB each direction, exact bytes, 1.8s reader pause, control after pause, close propagation"); |
||||
|
} |
||||
|
} |
||||
|
finally { server.Stop(); } |
||||
|
} |
||||
|
|
||||
|
private static void CheckBytes(Stream source, byte[] expected) |
||||
|
{ |
||||
|
byte[] buffer = new byte[1777]; |
||||
|
int offset = 0; |
||||
|
while (offset < expected.Length) |
||||
|
{ |
||||
|
int count = source.Read(buffer, 0, Math.Min(buffer.Length, expected.Length - offset)); |
||||
|
if (count == 0) throw new EndOfStreamException("Bridge closed before all bytes arrived"); |
||||
|
for (int i = 0; i < count; i++) |
||||
|
if (buffer[i] != expected[offset + i]) throw new Exception("Bridge changed byte " + (offset + i)); |
||||
|
offset += count; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> |
||||
|
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" /> |
||||
|
<PropertyGroup> |
||||
|
<OutputType>Exe</OutputType> |
||||
|
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion> |
||||
|
<OutputPath>bin\</OutputPath> |
||||
|
<AssemblyName>Verify-WifiDirect</AssemblyName> |
||||
|
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> |
||||
|
<RestoreProjectStyle>PackageReference</RestoreProjectStyle> |
||||
|
<TestBuildFolder Condition="'$(TestBuildFolder)' == ''">DirectName</TestBuildFolder> |
||||
|
</PropertyGroup> |
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="Microsoft.Windows.SDK.Contracts" Version="10.0.28000.2705" /> |
||||
|
<Reference Include="System" /> |
||||
|
<Reference Include="System.Core" /> |
||||
|
<Reference Include="DCT_test"> |
||||
|
<HintPath>..\DCT_test\bin\$(TestBuildFolder)\DCT_test.exe</HintPath> |
||||
|
</Reference> |
||||
|
<Compile Include="Verify-WifiDirect.cs" /> |
||||
|
<Compile Include="..\Verify-Transport.cs" /> |
||||
|
</ItemGroup> |
||||
|
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> |
||||
|
</Project> |
||||
Loading…
Reference in new issue