Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package com.px4.hawkeye.android

import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.px4.hawkeye.feature.replay.data.db.LibraryEntryEntity
import com.px4.hawkeye.feature.replay.data.db.ReplayLibraryDao
import java.io.File
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.GlobalContext

/**
* End-to-end library multi-select on a device: seeds real Room rows plus their on-disk
* payloads, walks the Select -> check -> "Play together" flow through the live UI, and
* verifies the staged inbox the native renderer reads (current.ulg + swarm_1.ulg in click
* order, .ready carrying the batch count).
*/
@RunWith(AndroidJUnit4::class)
class LibrarySwarmSelectionTest {

@get:Rule
val composeRule = createEmptyComposeRule()

private val dao: ReplayLibraryDao get() = GlobalContext.get().get()
private val filesDir: File =
ApplicationProvider.getApplicationContext<Context>().filesDir

private val robot by lazy { LibrarySelectionRobot(composeRule) }

@Before
fun seed() {
runBlocking {
clearLibrary()
val libraryDir = File(filesDir, "library").apply { mkdirs() }
SEED.forEach { (entity, payload) ->
File(libraryDir, entity.fileName).writeText(payload)
dao.insert(entity)
}
}
}

@After
fun cleanup() {
runBlocking {
clearLibrary()
SEED.forEach { (entity, _) -> File(File(filesDir, "library"), entity.fileName).delete() }
File(filesDir, "inbox").listFiles()?.forEach { it.delete() }
}
}

private suspend fun clearLibrary() {
dao.observeAll().first().forEach { dao.deleteById(it.id) }
}

@Test
fun selectionModeShowsCountAndPlayTogetherCta() {
ActivityScenario.launch(MainActivity::class.java).use {
robot
.openLibrary()
.enterSelectionMode()
.clickEntry(ALPHA)
.assertTitle("1 selected")
.clickEntry(BRAVO)
.assertTitle("2 selected")
.assertCta("Play together (2)")
}
}

@Test
fun playTogetherStagesTheBatchInClickOrder() {
ActivityScenario.launch(MainActivity::class.java).use {
robot
.openLibrary()
.enterSelectionMode()
.clickEntry(BRAVO) // clicked first: becomes drone 0 / current.ulg
.clickEntry(ALPHA)
.clickCta("Play together (2)")

val inbox = File(filesDir, "inbox")
val ready = File(inbox, ".ready")
composeRule.waitUntil(timeoutMillis = 10_000) {
ready.exists() && ready.readText().endsWith(" 2")
}
assertEquals(BRAVO_PAYLOAD, File(inbox, "current.ulg").readText())
assertEquals(ALPHA_PAYLOAD, File(inbox, "swarm_1.ulg").readText())
assertFalse(File(inbox, "swarm_2.ulg").exists())
assertTrue(ready.readText().endsWith(" 2"))
}
}

@Test
fun closingSelectionModeRestoresTheLibraryChrome() {
ActivityScenario.launch(MainActivity::class.java).use {
robot
.openLibrary()
.enterSelectionMode()
.clickEntry(ALPHA)
.exitSelectionMode()
.assertTitle("Replay library")
.assertCta("Open file")
}
}

@Test
fun systemBackExitsSelectionModeBeforeLeavingTheLibrary() {
ActivityScenario.launch(MainActivity::class.java).use {
robot
.openLibrary()
.enterSelectionMode()
.clickEntry(ALPHA)
.pressBack()
.assertTitle("Replay library")
.assertCta("Open file")
}
}

private companion object {
const val ALPHA = "swarm_alpha.ulg"
const val BRAVO = "swarm_bravo.ulg"
const val CHARLIE = "swarm_charlie.ulg"
const val ALPHA_PAYLOAD = "payload-alpha"
const val BRAVO_PAYLOAD = "payload-bravo"

// Distinct payloads so the staged-order assertion can tell the files apart.
val SEED = listOf(
LibraryEntryEntity("s1", ALPHA, 13L, 30L, "s1.ulg") to ALPHA_PAYLOAD,
LibraryEntryEntity("s2", BRAVO, 13L, 20L, "s2.ulg") to BRAVO_PAYLOAD,
LibraryEntryEntity("s3", CHARLIE, 13L, 10L, "s3.ulg") to "payload-charlie",
)
}
}

private class LibrarySelectionRobot(private val rule: ComposeTestRule) {

fun openLibrary() = apply {
awaitText("Replay a flight")
rule.onNodeWithText("Replay a flight").performClick()
awaitText("Select")
}

fun enterSelectionMode() = apply {
rule.onNodeWithText("Select").performClick()
awaitText("0 selected")
}

fun exitSelectionMode() = apply {
rule.onNodeWithContentDescription("Exit selection").performClick()
}

fun pressBack() = apply {
rule.waitForIdle()
androidx.test.espresso.Espresso.pressBack()
rule.waitForIdle()
}

fun clickEntry(displayName: String) = apply {
rule.onNodeWithText(displayName).performClick()
}

fun clickCta(label: String) = apply {
awaitText(label)
rule.onNodeWithText(label).performClick()
}

fun assertTitle(title: String) = apply {
rule.onNodeWithText(title).assertIsDisplayed()
}

fun assertCta(label: String) = apply {
rule.onNodeWithText(label).assertIsDisplayed()
}

private fun awaitText(text: String) {
rule.waitUntil(timeoutMillis = 5_000) {
rule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package com.px4.hawkeye.android

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.px4.hawkeye.android.render.SwarmWheelSnapshot
import com.px4.hawkeye.android.render.swarm.DroneWheelItemUi
import com.px4.hawkeye.android.render.swarm.SwarmWheelAction
import com.px4.hawkeye.android.render.swarm.SwarmWheelScreen
import com.px4.hawkeye.android.render.swarm.SwarmWheelState
import com.px4.hawkeye.core.designsystem.HawkeyeDronePalette
import com.px4.hawkeye.core.designsystem.HawkeyeTheme
import com.px4.hawkeye.core.designsystem.wheel.WheelMenuTestTags
import com.px4.hawkeye.core.presentation.UiText
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Drives [SwarmWheelScreen] with scripted native gesture snapshots — the same sequence the
* polling ViewModel produces — and verifies the projection into the wheel widget: open,
* hover, one-shot selection on release, and close-without-selection paths.
*/
@RunWith(AndroidJUnit4::class)
class SwarmWheelScreenTest {

@get:Rule
val composeRule = createComposeRule()

private val robot by lazy { SwarmWheelRobot(composeRule) }

@Test
fun wheelAppearsWhileTheNativeGestureIsOpenAndClosesOnIdle() {
robot
.setContent()
.assertWheelGone()
.open(CENTER)
.assertWheelVisible()
.idle()
.assertWheelGone()
.assertSelections()
}

@Test
fun releaseOverASliceSelectsThatDroneExactlyOnce() {
robot
.setContent()
.open(CENTER)
.moveTo(RIGHT_SLICE)
.release(RIGHT_SLICE)
.assertWheelGone()
// A later unrelated snapshot with the same seq must not replay the selection.
.idle()
.assertSelections(0)
}

@Test
fun tornSnapshotWithStaleOpenPhaseDoesNotReopenAfterRelease() {
// The native publish is not a single atomic block: a poll can pair a stale OPEN
// phase with a fresh release seq. The release must win and the wheel stay closed.
robot
.setContent()
.open(CENTER)
.moveTo(RIGHT_SLICE)
.releaseKeepingOpenPhase(RIGHT_SLICE)
.assertWheelGone()
.assertSelections(0)
}

@Test
fun rejectedGestureClosesWithoutSelection() {
robot
.setContent()
.open(CENTER)
.moveTo(RIGHT_SLICE)
.reject()
.assertWheelGone()
.assertSelections()
}

@Test
fun releaseInTheHubDeadZoneSelectsNothing() {
robot
.setContent()
.open(CENTER)
.moveTo(RIGHT_SLICE)
.release(CENTER)
.assertWheelGone()
.assertSelections()
}

private companion object {
// Well inside any screen; slice offset far beyond the 64 dp hub dead zone.
val CENTER = Offset(540f, 800f)
val RIGHT_SLICE = Offset(540f + 400f, 800f) // 3 o'clock = slice 0 of two
}
}

private class SwarmWheelRobot(private val rule: ComposeContentTestRule) {

private lateinit var state: MutableState<SwarmWheelState>
private val actions = mutableListOf<SwarmWheelAction>()

fun setContent() = apply {
state = mutableStateOf(
SwarmWheelState(
items = listOf(
DroneWheelItemUi(
label = UiText.DynamicString("Drone 1"),
hubLabel = UiText.DynamicString("alpha.ulg"),
accentColor = HawkeyeDronePalette.colors[0],
),
DroneWheelItemUi(
label = UiText.DynamicString("Drone 2"),
hubLabel = UiText.DynamicString("bravo.ulg"),
accentColor = HawkeyeDronePalette.colors[1],
),
),
gesture = SwarmWheelSnapshot.Idle.copy(droneCount = 2),
),
)
rule.setContent {
HawkeyeTheme {
SwarmWheelScreen(state = state.value, onAction = { actions += it })
}
}
}

private fun gesture(transform: (SwarmWheelSnapshot) -> SwarmWheelSnapshot) = apply {
state.value = state.value.copy(gesture = transform(state.value.gesture))
rule.waitForIdle()
}

fun open(at: Offset) = gesture {
it.copy(
phase = SwarmWheelSnapshot.PHASE_OPEN,
centerX = at.x, centerY = at.y,
fingerX = at.x, fingerY = at.y,
)
}

fun moveTo(finger: Offset) = gesture { it.copy(fingerX = finger.x, fingerY = finger.y) }

fun release(at: Offset) = gesture {
it.copy(
phase = SwarmWheelSnapshot.PHASE_IDLE,
releaseSeq = it.releaseSeq + 1,
releaseX = at.x, releaseY = at.y,
)
}

/** A torn read: the seq bumped but the phase field still carries the stale OPEN. */
fun releaseKeepingOpenPhase(at: Offset) = gesture {
it.copy(
phase = SwarmWheelSnapshot.PHASE_OPEN,
releaseSeq = it.releaseSeq + 1,
releaseX = at.x, releaseY = at.y,
)
}

fun reject() = gesture { it.copy(phase = SwarmWheelSnapshot.PHASE_REJECTED) }

fun idle() = gesture { it.copy(phase = SwarmWheelSnapshot.PHASE_IDLE) }

fun assertWheelVisible() = apply {
rule.onNodeWithTag(WheelMenuTestTags.WHEEL).assertIsDisplayed()
}

fun assertWheelGone() = apply {
rule.onNodeWithTag(WheelMenuTestTags.WHEEL).assertDoesNotExist()
}

fun assertSelections(vararg drones: Int) = apply {
assertEquals(drones.map { SwarmWheelAction.OnDroneSelected(it) }, actions)
}
}
1 change: 1 addition & 0 deletions android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ set(MAVLINK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../lib/c_library_v2")
add_library(hawkeye SHARED
android_main.c
replay_jni.c
swarm_jni.c
${HAWKEYE_SRC}/scene.c
${HAWKEYE_SRC}/vehicle.c
${HAWKEYE_SRC}/theme.c
Expand Down
Loading
Loading