اختبار مكوّنات وواجهات A2UI

توفّر مكتبة androidx.a2ui.compose:compose-ui-testing للاختبار واجهات برمجة تطبيقات للاختبار تستخدم نمطًا للوحدة التحكّم يتوافق مع مكتبات الاختبار في Jetpack، مثل TestNavHostController في Navigation.

على عكس مكوّنات Jetpack Compose العادية التي تستخدم مَعلمات ثابتة وتعرض واجهة مستخدم، تكون مكوّنات A2UI سياقية. تعتمد هذه المكتبات على A2uiComponentScope من أجل تقييم عمليات ربط البيانات الديناميكية، وإرسال الإجراءات الصادرة إلى الوكيل، والكتابة مرة أخرى إلى عمليات ربط البيانات الثنائية الاتجاه، وتوسيع قوالب العناصر الفرعية الديناميكية.

تسهّل واجهات برمجة التطبيقات الخاصة بالاختبار عملية إعداد الاختبار أثناء توفير مثيلات 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()
}

تفاصيل التنفيذ

توضّح الأقسام التالية عمليات إلغاء المكوّنات والتحقّق من صحة المخطط ومزامنة الروتينات الفرعية في إطار الاختبار.

تتضمّن مكتبة الاختبار واجهات برمجة التطبيقات الأساسية التالية:

  • A2uiTestController: دوال إنشاء الإضافات وواجهة وحدة التحكّم الرئيسية في الاختبار
  • A2uiComponentStub: عناصر بديلة وعناصر تتجاوز العناصر الأصلية للأطفال والكتالوجات
  • A2uiTestSurface: أداة مساعدة مركّبة بسيطة تنشئ مساحة اختبار.

تجاوز المكوّنات مقابل المحاكاة العادية

للتخلّص من أُطر المحاكاة الخارجية المعقّدة، يتم تجاوز المكوّنات الفرعية والتبعيات الخارجية باستخدام عناصر نائبة لواجهة المستخدم (A2uiComponentStub). يعترض A2uiComponentStub.withId مثيلاً معيّنًا من المكوّن حسب رقم التعريف، بينما يتجاوز A2uiComponentStub.withType عملية العرض لنوع فهرس كامل.

التحقّق من صحة المخطط بسرعة

يفرض إطار الاختبار عقد بروتوكول A2UI بشكل متزامن. عندما يبدأ عنصر التحكّم أو يحدّث المكوّنات، يتم تنفيذ A2uiCoreSchemaValidator على الحِزم المتوفّرة. في حال ضبط سمة غير صالحة، مثل حقل مطلوب غير متوفّر أو عدم تطابق النوع، يتعطّل الاختبار على الفور مع ظهور A2uiValidationException.

مزامنة الروتينات الفرعية

تتكامل A2uiTestController.start مع سياق روتين الاختبار المشترك الذي توفّره runComposeUiTest(). يستخرِج هذا الإجراء currentCoroutineContext()، ويربط حلقات الخلفية بـ Job منفصل، ويلغي نفسه تلقائيًا عند اكتمال كتلة الاختبار، ما يمنع تنفيذ الاختبارات المعلقة. waitForIdle() تنتظر هذه الدالة إلى أن تنتهي جميع إجراءات الخلفية المعلقة.