בדיקת רכיבים ופלטפורמות של A2UI

ספריית הבדיקה androidx.a2ui.compose:compose-ui-testing מספקת ממשקי API לבדיקה שמשתמשים בדפוס בקר שמאפיין את ספריות הבדיקה של Jetpack, כמו TestNavHostController של Navigation.

בניגוד לרכיבים רגילים של Jetpack Compose שמקבלים פרמטרים סטטיים ויוצרים ממשק משתמש, רכיבי A2UI הם תלויי-הקשר. הם מסתמכים על A2uiComponentScope כדי להעריך קשרי נתונים דינמיים, לשלוח פעולות יוצאות לסוכן, לכתוב בחזרה לקשרי נתונים דו-כיווניים ולבצע הרחבה של תבניות צאצא דינמיות.

ממשקי ה-API לבדיקה מפשטים את הגדרת הבדיקה בזמן הקצאת מופעים אמיתיים של A2uiMessageProcessor, והרצת שגרות המשנה שלהם שקשורות לסביבת הבדיקה של Compose.

רכיבים מבודדים

אתם יכולים לוודא שרכיב מסוים פותר את הנתונים שלו, שולח פעולות ומבצע רינדור בצורה תקינה בתוך ערכת הנושא של מערכת העיצוב:

@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"])
}

מצבי פלטפורמה

אפשר לבדוק מארחים של פלטפורמות כמו A2uiSurface, כולל המצבים והמעברים שלהם:

@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()
}

קישור דו-כיווני

אתם יכולים לבדוק רכיבים כמו שדות טקסט שכותבים בחזרה למודל הנתונים במהלך קלט של משתמשים, ולאמת עדכונים תגובתיים כשהסוכן משנה את מודל הנתונים:

@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()
}

רכיבים עם צאצאים שנוצרו באמצעות תבנית

אתם יכולים לבדוק רכיבים שמיועדים להצגת אוספים של ילדים שהוגדרו באמצעות תבניות A2UI ChildList:

@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()
}

גיבוי לשגיאות בסוכן

אתם יכולים לוודא שהממשקים והרכיבים מטפלים בשגיאות של הסוכן, כמו הזיות, בצורה חלקה:

@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)
}

רינדור הדרגתי

אפשר לבדוק מצבי ביניים שבהם רכיב אב נטען אבל רכיבי צאצא עדיין ממתינים:

@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()
}

פרטי ההטמעה

בקטעים הבאים מוסבר על שינוי מברירת המחדל של רכיבים, אימות סכימה וסנכרון של שגרות המשך (coroutine) במסגרת הבדיקה.

ספריית הבדיקות כוללת את ממשקי ה-API העיקריים הבאים:

  • A2uiTestController: פונקציות בנאי של תוספים וממשק ראשי של בקר בדיקה.
  • A2uiComponentStub: stub ו-override לרכיבי צאצא וקטלוג.
  • A2uiTestSurface: כלי קל משקל שאפשר להרכיב ממנו כלי גדול יותר, והוא מאפשר להציג משטח בדיקה.

החלפת רכיבים לעומת יצירת אובייקטים לחיקוי רגילים

כדי להימנע משימוש במסגרות מורכבות של יצירת אובייקטים מדומים של צד שלישי, רכיבי צאצא ותלות חיצונית מועברים באמצעות stub של ממשק משתמש (A2uiComponentStub). הפונקציה A2uiComponentStub.withId מיירטת מופע ספציפי של רכיב לפי מזהה, ואילו הפונקציה A2uiComponentStub.withType מבטלת את העיבוד של סוג קטלוג שלם.

אימות סכימה מהיר

מסגרת הבדיקה אוכפת את חוזה פרוטוקול A2UI באופן סינכרוני. כשבקר מאתחל או מעדכן רכיבים, הוא מריץ A2uiCoreSchemaValidator מול מטען ייעודי למטרה מסוימת שסופק. אם מוגדר מאפיין לא תקין, כמו שדה חובה חסר או אי התאמה בין סוגים, הבדיקה קורסת באופן מיידי עם A2uiValidationException.

סנכרון של קורוטינות

A2uiTestController.start hooks into the test coroutine context provided by runComposeUiTest(). הוא מחלץ את currentCoroutineContext(), ממפה לולאות ברקע ל-Job נפרד ומבטל את עצמו באופן אוטומטי כשהבלוק של הבדיקה מסתיים, כדי למנוע ביצועים של בדיקות לא תקינות. waitForIdle() הפונקציה ממתינה לסיום כל הקורוטינות התלויות ברקע.