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:
John Smith
2026-09-11 10:40:12 -04:00
parent c3eecde8e8
commit 1861f5dafa
9 changed files with 158 additions and 3 deletions
+12
View File
@@ -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)
}
+8
View File
@@ -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
+2
View File
@@ -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" }