mirror of
https://github.com/project-dorado/dorado-hd.git
synced 2026-10-11 21:10:01 +00:00
test(ui): Phase 0 harness — Compose UI tests under Robolectric
- Add ui-test-junit4/ui-test-manifest (test classpath) and enable unitTests.includeAndroidResources; pin Robolectric sdk=34. - Debug-only deep link dorado://app/<slug> (release ignores) for automation. - TestDoradoApp disables the app-level Media3 connect, which cannot bind under Robolectric; DoradoApp is open with an autoConnectPlayback flag. - MiniAppTestSupport: real app graph + real host path (MiniAppScreen) inside DoradoTheme/DeviceCanvas at DEVICE 480x272, PORTRAIT 400x800 and LANDSCAPE 800x360; MiniAppHarnessTest proves it renders.
This commit is contained in:
@@ -45,6 +45,14 @@ android {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
// Robolectric needs the merged manifest/resources so Compose UI tests
|
||||
// can launch the app and test activities (ui-test-manifest).
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -82,4 +90,8 @@ dependencies {
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
// Compose UI parity suite (Robolectric-hosted), Phase 0 of the UI parity program.
|
||||
testImplementation(platform(libs.compose.bom))
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="doradohd" android:host="oauth" />
|
||||
</intent-filter>
|
||||
<!-- Debug automation: dorado://app/<slug> opens a mini-app directly
|
||||
(handled in code only for debug builds; see MainActivity). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="dorado" android:host="app" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
|
||||
@@ -54,11 +54,22 @@ class DoradoGraph(
|
||||
val appState: com.heretek.dorado_hd.data.repo.AppStateRepository,
|
||||
)
|
||||
|
||||
class DoradoApp : Application() {
|
||||
open class DoradoApp : Application() {
|
||||
|
||||
lateinit var graph: DoradoGraph
|
||||
private set
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Media3's `MediaController` cannot bind a session service under
|
||||
* Robolectric (the shadow passes a null ComponentName). UI tests run
|
||||
* with a [Test] application that flips this before `onCreate`; the
|
||||
* shipped app always connects.
|
||||
*/
|
||||
@Volatile
|
||||
var autoConnectPlayback: Boolean = true
|
||||
}
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -129,8 +140,10 @@ class DoradoApp : Application() {
|
||||
cloudSignIn, cloudSignInCallback, cloudUpdates, playCountStore, appState,
|
||||
)
|
||||
|
||||
appScope.launch {
|
||||
controller.connect()
|
||||
if (autoConnectPlayback) {
|
||||
appScope.launch {
|
||||
controller.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate the M9.3 audio-feature cache from Room so similarity queries
|
||||
|
||||
@@ -36,12 +36,30 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
requestMediaPermissionsIfNeeded()
|
||||
handleCloudRedirect(intent)
|
||||
handleAppDeepLink(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleCloudRedirect(intent)
|
||||
handleAppDeepLink(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug-only `dorado://app/<slug>` route, used by the emulator crawl and
|
||||
* UI parity automation to open a mini-app deterministically. Release builds
|
||||
* ignore it; unknown slugs are ignored.
|
||||
*/
|
||||
private fun handleAppDeepLink(intent: Intent?) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
val data = intent?.data ?: return
|
||||
if (!data.scheme.equals("dorado", ignoreCase = true)) return
|
||||
if (data.host != "app") return
|
||||
val slug = data.lastPathSegment ?: return
|
||||
if (com.heretek.dorado_hd.ui.apps.DoradoApps.byId(slug) == null) return
|
||||
val graph = (application as DoradoApp).graph
|
||||
graph.nav.push(com.heretek.dorado_hd.ui.nav.DoradoDestination.MiniApp(slug))
|
||||
}
|
||||
|
||||
private fun requiredMediaPermissions(): Array<String> {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.heretek.dorado_hd
|
||||
|
||||
/**
|
||||
* Robolectric application for the Compose UI parity suite. Media3 cannot bind
|
||||
* its session service under Robolectric, so the app-level playback connect is
|
||||
* disabled before `super.onCreate`; everything else (graph, Room, DataStore,
|
||||
* repositories) is the real wiring.
|
||||
*/
|
||||
class TestDoradoApp : DoradoApp() {
|
||||
override fun onCreate() {
|
||||
autoConnectPlayback = false
|
||||
super.onCreate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.heretek.dorado_hd.ui
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import com.heretek.dorado_hd.TestDoradoApp
|
||||
|
||||
/**
|
||||
* Harness validation: proves a registered app renders through the real host
|
||||
* path at all three parity configurations without throwing. The catalog-wide
|
||||
* suite (`AppSmokeSuite`) builds on this.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = TestDoradoApp::class)
|
||||
class MiniAppHarnessTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `calculator renders at every parity configuration`() {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
androidx.compose.foundation.layout.Row {
|
||||
HarnessMode.entries.forEach { mode ->
|
||||
MiniAppHarness("calculator", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.mainClock.advanceTimeBy(500)
|
||||
val root = compose.onRoot().fetchSemanticsNode()
|
||||
assertTrue("harness produced an empty semantics tree", root.children.isNotEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.heretek.dorado_hd.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.heretek.dorado_hd.DoradoApp
|
||||
import com.heretek.dorado_hd.DoradoGraph
|
||||
import com.heretek.dorado_hd.design.DoradoTheme
|
||||
import com.heretek.dorado_hd.design.components.DeviceCanvas
|
||||
import com.heretek.dorado_hd.ui.screens.MiniAppScreen
|
||||
|
||||
/**
|
||||
* The three canonical parity configurations. Device mode renders the fixed
|
||||
* 480x272 canvas; adaptive modes exercise the density-scaled layouts at a
|
||||
* phone portrait and a wide landscape size (see design/components/DeviceCanvas.kt).
|
||||
*/
|
||||
enum class HarnessMode(val width: Dp, val height: Dp, val deviceMode: Boolean) {
|
||||
DEVICE(480.dp, 272.dp, deviceMode = true),
|
||||
PORTRAIT(400.dp, 800.dp, deviceMode = false),
|
||||
LANDSCAPE(800.dp, 360.dp, deviceMode = false),
|
||||
}
|
||||
|
||||
/** The real application graph (Robolectric builds DoradoApp in every test). */
|
||||
fun appGraph(): DoradoGraph =
|
||||
ApplicationProvider.getApplicationContext<DoradoApp>().graph
|
||||
|
||||
/**
|
||||
* Renders one registered mini-app through the real host path (registry lookup
|
||||
* + `MiniAppScreen`) inside the real theme and canvas, at [mode]'s size.
|
||||
*/
|
||||
@Composable
|
||||
fun MiniAppHarness(slug: String, mode: HarnessMode) {
|
||||
val graph = appGraph()
|
||||
DoradoTheme {
|
||||
CompositionLocalProvider(LocalDoradoGraph provides graph) {
|
||||
Box(Modifier.requiredSize(mode.width, mode.height)) {
|
||||
DeviceCanvas(deviceMode = mode.deviceMode) { canvasWidth, _ ->
|
||||
MiniAppScreen(slug, canvasWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
sdk=34
|
||||
@@ -32,6 +32,8 @@ compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||
compose-animation = { group = "androidx.compose.animation", name = "animation" }
|
||||
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
|
||||
Reference in New Issue
Block a user