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