تست اجزا و سطوح A2UI

کتابخانه تست androidx.a2ui.compose:compose-ui-testing APIهای تستی را ارائه می‌دهد که از یک الگوی کنترلر که مختص کتابخانه‌های تست Jetpack است، مانند TestNavHostController از Navigation، استفاده می‌کنند.

برخلاف کامپوننت‌های استاندارد Jetpack Compose که پارامترهای استاتیک می‌گیرند و رابط کاربری (UI) منتشر می‌کنند، کامپوننت‌های A2uiComponentScope وابسته به متن هستند. آن‌ها برای ارزیابی اتصال‌های داده پویا، ارسال اقدامات خروجی به عامل، نوشتن مجدد به اتصال‌های داده دوطرفه و ایجاد قالب‌های فرزند پویا، به 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()
}

جزئیات پیاده‌سازی

بخش‌های بعدی، لغو کامپوننت‌ها، اعتبارسنجی طرحواره و همگام‌سازی کوروتین در چارچوب تست را توضیح می‌دهند.

کتابخانه تست، API های اصلی زیر را معرفی می‌کند:

  • A2uiTestController : توابع سازنده افزونه و رابط کنترل کننده تست اصلی.
  • A2uiComponentStub : خلاصه‌ها و بازنویسی‌ها برای کامپوننت‌های فرزند و کاتالوگ.
  • A2uiTestSurface : یک ابزار سبک و قابل ترکیب که یک سطح آزمایشی را نصب می‌کند.

لغو کامپوننت در مقابل تقلید استاندارد

برای حذف چارچوب‌های تقلیدی شخص ثالث سنگین، کامپوننت‌های فرزند و وابستگی‌های خارجی با استفاده از UI stubها ( A2uiComponentStub ) دور زده می‌شوند. A2uiComponentStub.withId یک نمونه کامپوننت خاص را بر اساس شناسه رهگیری می‌کند، در حالی که A2uiComponentStub.withType رندرینگ را برای کل یک نوع کاتالوگ لغو می‌کند.

اعتبارسنجی طرحواره بدون خطا

چارچوب تست، قرارداد پروتکل A2UI را به صورت همزمان اجرا می‌کند. هنگامی که کنترلر، کامپوننت‌ها را مقداردهی اولیه یا به‌روزرسانی می‌کند، A2uiCoreSchemaValidator را برای بارهای داده شده اجرا می‌کند. اگر یک ویژگی نامعتبر تنظیم شود، مانند یک فیلد الزامی از دست رفته یا عدم تطابق نوع، تست بلافاصله با یک A2uiValidationException از کار می‌افتد.

همگام‌سازی کوروتین

A2uiTestController.start به زمینه کوروتین تست ارائه شده توسط runComposeUiTest() قلاب می‌کند. این currentCoroutineContext() را استخراج می‌کند، حلقه‌های پس‌زمینه را به یک Job جدا نگاشت می‌کند و پس از اتمام بلوک تست، به طور خودکار خود را لغو می‌کند و از اجرای تست‌های معلق جلوگیری می‌کند. waitForIdle() منتظر می‌ماند تا تمام کوروتین‌های پس‌زمینه در حال انتظار به پایان برسند.