Tester les composants et les surfaces A2UI

La bibliothèque de test androidx.a2ui.compose:compose-ui-testing fournit des API de test qui utilisent un modèle de contrôleur idiomatique pour les bibliothèques de test Jetpack, telles que TestNavHostController de Navigation.

Contrairement aux composants Jetpack Compose standards qui prennent des paramètres statiques et émettent une UI, les composants A2UI sont contextuels. Ils s'appuient sur A2uiComponentScope pour évaluer les liaisons de données dynamiques, distribuer les actions sortantes à l'agent, écrire dans les liaisons de données bidirectionnelles et développer les modèles enfants dynamiques.

Les API de test simplifient la configuration des tests tout en provisionnant de véritables instances A2uiMessageProcessor, en exécutant leurs coroutines liées à l'environnement de test Compose.

Composants isolés

Vous pouvez vérifier qu'un composant individuel résout ses données, distribue des actions et s'affiche correctement dans le thème de votre système de conception :

@Test
fun button_resolvesStubChildAndDispatchesAction() = runComposeUiTest {
    // 1. Create the test controller
    val controller = A2uiTestController(
        // Provide a catalog containing the component under test
        catalog = CustomComponentCatalog,
        // Configure the component under test with concrete properties
        initialComponents = listOf(
            A2uiComponentPayload(
                id = "root",
                type = "Button",
                properties = mapOf(
                    "child" to "btn_text",
                    "variant" to "primary",
                    "action" to mapOf(
                        "event" to mapOf(
                            "name" to "submit_form",
                            "context" to mapOf("username" to mapOf("path" to "/user/name")),
                        ),
                    ),
                ),
            ),
            A2uiComponentPayload("btn_text"),
        ),
        // Stub the required child component
        componentStubs = listOf(
            A2uiComponentStub.withId("btn_text") { _, modifier ->
                Text("Submit", modifier = modifier)
            },
        ),
        // Provide initial dynamic data
        initialData = mapOf("user" to mapOf("name" to "Test User")),
    )

    // 2. Start background processing and initialize the surface
    val surface = controller.start()

    // 3. Mount the UI
    setContent {
        A2uiTestSurface(surface)
    }

    // 4. Interact using standard Compose UI semantics
    onNodeWithText("Submit").performClick()

    // 5. Wait for Compose and A2UI background processes to settle
    waitForIdle()
    controller.waitForIdle()

    // 6. Assert outbound actions were correctly evaluated and intercepted
    val action = controller.dispatchedActions.single() as A2uiEventAction
    assertEquals("submit_form", action.eventName)
    assertEquals("Test User", action.context["username"])
}

États de surface

Vous pouvez tester les hôtes de surface tels que A2uiSurface, y compris leurs états et transitions :

@Test
fun surface_displaysLoading_thenTransitionsToContent() = runComposeUiTest {
    // 1. Create an empty controller to simulate a pending network request
    val controller = A2uiTestController(
        catalog = CustomComponentCatalog,
        // Pre-register a stub for the expected root component type
        componentStubs = listOf(
            A2uiComponentStub.withType("RootLayout") { _, modifier ->
                Text("Content Ready", modifier = modifier)
            },
        ),
    )
    val surface = controller.start()

    // 2. Mount the surface UI
    setContent {
        A2uiSurface(surfaceModel = surface)
    }

    // 3. Assert the loading placeholder is active
    onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertExists()

    // 4. Simulate the agent pushing the layout payload over the network
    controller.updateComponent(
        id = "root",
        type = "RootLayout",
        properties = emptyMap(),
    )

    // 5. Wait for the data layer and animation to settle
    controller.waitForIdle()
    waitForIdle()

    // 6. Assert the loading state is gone and content is visible
    onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertDoesNotExist()
    onNodeWithText("Content Ready").assertIsDisplayed()
}

Liaison bidirectionnelle

Vous pouvez tester des composants tels que les champs de texte qui réécrivent dans le modèle de données lors de la saisie par l'utilisateur et vérifier les mises à jour réactives lorsque l'agent modifie le modèle de données :

@Test
fun textField_writesToDataModelAndReactsToAgent() = runComposeUiTest {
    val controller = A2uiTestController(
        catalog = CustomComponentCatalog,
        initialComponents = listOf(
            A2uiComponentPayload(
                id = "root",
                type = "TextField",
                properties = mapOf(
                    "label" to "Username",
                    "value" to mapOf("path" to "/form/username"),
                ),
            ),
        ),
        initialData = mapOf("form" to mapOf("username" to "Initial")),
    )
    val surface = controller.start()

    setContent {
        A2uiTestSurface(surface)
    }

    // 1. User interaction updates the global DataModel locally
    onNodeWithText("Initial").performTextReplacement("LocallyTyped")
    waitForIdle()

    // 2. Assert the component wrote back to the DataModel
    assertEquals("LocallyTyped", controller.getData<String>("/form/username"))

    // 3. Simulate the agent pushing a data update for the same path
    controller.updateData("/form/username", "ServerOverridden")
    controller.waitForIdle()

    // 4. Assert the component reactively updated the UI
    onNodeWithText("ServerOverridden").assertIsDisplayed()
}

Composants avec des enfants basés sur des modèles

Vous pouvez tester les composants conçus pour afficher des collections d'enfants définies à l'aide de modèles ChildList A2UI :

@Test
fun column_rendersDynamicChildTemplates() = runComposeUiTest {
    val controller = A2uiTestController(
        catalog = CustomComponentCatalog,
        initialData = mapOf(
            "catalog" to mapOf(
                "products" to listOf(
                    mapOf("title" to "Camera"),
                    mapOf("title" to "Laptop"),
                ),
            ),
        ),
        initialComponents = listOf(
            A2uiComponentPayload(
                id = "root",
                type = "Column",
                properties = mapOf(
                    "children" to mapOf(
                        "path" to "/catalog/products",
                        "componentId" to "product_template",
                    ),
                ),
            ),
            // Bind the initial properties for the dynamically instantiated
            // template stub.
            A2uiComponentPayload(
                id = "product_template",
                properties = mapOf("title" to mapOf("path" to "title")),
            ),
        ),
        componentStubs = listOf(
            A2uiComponentStub.withId(id = "product_template") { props, modifier ->
                val titleProp = remember { A2uiProperty.dynamicString("title") }
                val title = props.bind(titleProp) ?: "Unknown"
                Text(text = "Stubbed: $title", modifier = modifier)
            },
        ),
    )
    val surface = controller.start()
    setContent { A2uiTestSurface(surface) }

    // Verify the template was instantiated twice with relative data
    onNodeWithText("Stubbed: Camera").assertExists()
    onNodeWithText("Stubbed: Laptop").assertExists()

    // Simulate appending a new item to the data model array
    controller.updateData("/catalog/products/-", mapOf("title" to "Tablet"))
    controller.waitForIdle()

    // Verify the Column dynamically instantiated a new child stub
    onNodeWithText("Stubbed: Tablet").assertExists()
}

Solutions de repli en cas d'erreur d'agent

Vous pouvez vérifier que les surfaces et les composants gèrent correctement les erreurs de l'agent, telles que les hallucinations :

@Test
fun surface_displaysErrorFallback_onAgentHallucination() = runComposeUiTest {
    val controller = A2uiTestController(catalog = CustomComponentCatalog)
    val surface = controller.start()

    // 1. Mount the surface orchestrator with error boundaries
    setContent { A2uiSurface(surfaceModel = surface) }

    // 2. Simulate an agent hallucinating a broken component layout
    controller.failComponent(
        id = "root",
        exception = A2uiException.A2uiValidationException(
            message = "HallucinatedType",
            path = "/components/root"
        ),
    )
    controller.waitForIdle()

    // 3. Assert the surface displayed the fallback error state
    onNodeWithText("Failed to load: HallucinatedType").assertIsDisplayed()

    // 4. Assert the core layer dispatched an error to the server
    val errorMsg = controller.outboundErrors.single()
    assertEquals("VALIDATION_FAILED", errorMsg.code)
}

Rendu progressif

Vous pouvez tester les états intermédiaires dans lesquels un composant parent a été chargé, mais les composants enfants sont toujours en attente :

@Test
fun progressiveRendering_parentRendersWhileChildIsPending() = runComposeUiTest {
    // 1. Mount the parent, omitting the child instance
    val controller = A2uiTestController(
        catalog = CustomComponentCatalog,
        initialComponents = listOf(
            A2uiComponentPayload(
                id = "root",
                type = "Button",
                properties = mapOf(
                    "child" to "delayed_text_id",
                    "action" to mapOf("event" to mapOf("name" to "click")),
                ),
            ),
        ),
    )
    val surface = controller.start()
    setContent {
        A2uiTestSurface(surface)
    }

    // 2. Initial state: parent is rendered, child displays loading state
    onNodeWithText("Submit").assertDoesNotExist()
    onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertExists()

    // 3. Simulate arrival of the child component
    controller.updateComponent(
        id = "delayed_text_id",
        type = "Text",
        properties = mapOf("text" to "Submit"),
    )
    controller.waitForIdle()

    // 4. Assert that progressive rendering completed
    onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertDoesNotExist()
    onNodeWithText("Submit").assertIsDisplayed()
}

Détails de mise en œuvre

Les sections suivantes expliquent les remplacements de composants, la validation de schéma et la synchronisation des coroutines dans le framework de test.

La bibliothèque de test introduit les API principales suivantes :

  • A2uiTestController : fonctions de constructeur d'extension et interface de contrôleur de test principal.
  • A2uiComponentStub : stubs et remplacements pour les composants enfants et de catalogue.
  • A2uiTestSurface : utilitaire composable léger qui monte une surface de test.

Remplacements de composants et simulation standard

Pour éliminer les frameworks de simulation tiers lourds, les composants enfants et les dépendances externes sont contournés à l'aide de stubs d'UI (A2uiComponentStub). A2uiComponentStub.withId intercepte une instance de composant spécifique par ID, tandis que A2uiComponentStub.withType remplace le rendu pour un type de catalogue entier.

Validation rapide du schéma

Le framework de test applique le contrat de protocole A2UI de manière synchrone. Lorsque le contrôleur initialise ou met à jour des composants, il exécute A2uiCoreSchemaValidator sur les charges utiles fournies. Si une propriété non valide est définie, par exemple un champ obligatoire manquant ou une incompatibilité de type, le test plante immédiatement avec un A2uiValidationException.

Synchronisation des coroutines

A2uiTestController.start s'accroche au contexte de coroutine de test fourni par runComposeUiTest(). Il extrait currentCoroutineContext(), mappe les boucles d'arrière-plan à un Job détaché et s'annule automatiquement lorsque le bloc de test est terminé, ce qui évite les exécutions de test inutiles. waitForIdle() attend que toutes les coroutines d'arrière-plan en attente soient terminées.