ทดสอบคอมโพเนนต์และแพลตฟอร์ม A2UI

androidx.a2ui.compose:compose-ui-testing ไลบรารีการทดสอบมี API การทดสอบ ที่ใช้รูปแบบคอนโทรลเลอร์ซึ่งเป็นลักษณะเฉพาะของไลบรารีการทดสอบ Jetpack เช่น TestNavHostController ของ Navigation

คอมโพเนนต์ A2UI เป็นคอมโพเนนต์ตามบริบท ซึ่งแตกต่างจากคอมโพเนนต์ Jetpack Compose มาตรฐานที่ใช้พารามิเตอร์แบบคงที่และปล่อย UI ออกมา โดยจะใช้ A2uiComponentScope เพื่อ ประเมินการเชื่อมโยงข้อมูลแบบไดนามิก ส่งการดำเนินการขาออกไปยังเอเจนต์ เขียน กลับไปยังการเชื่อมโยงข้อมูลแบบ 2 ทาง และขยายเทมเพลตย่อยแบบไดนามิก

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

สถานะของ Surface

คุณสามารถทดสอบโฮสต์ของแพลตฟอร์ม เช่น 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()
}

การเชื่อมโยงแบบ 2 ทาง

คุณสามารถทดสอบคอมโพเนนต์ต่างๆ เช่น ช่องข้อความที่เขียนกลับไปยังโมเดลข้อมูล ระหว่างข้อมูลจากผู้ใช้ และยืนยันการอัปเดตแบบรีแอกทีฟเมื่อเอเจนต์เปลี่ยนแปลงโมเดลข้อมูลได้โดยทำดังนี้

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

การสำรองข้อมูลข้อผิดพลาดสำหรับข้อผิดพลาดของ Agent

คุณสามารถยืนยันว่าแพลตฟอร์มและคอมโพเนนต์จัดการข้อผิดพลาดของเอเจนต์ เช่น การหลอน ได้อย่างราบรื่นโดยทำดังนี้

@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: ยูทิลิตีที่ประกอบกันได้แบบเบาซึ่งติดตั้งพื้นผิวทดสอบ

การลบล้างคอมโพเนนต์เทียบกับการจำลองมาตรฐาน

ระบบจะข้ามคอมโพเนนต์ย่อยและการอ้างอิงภายนอกโดยใช้ Stub UI (A2uiComponentStub) เพื่อหลีกเลี่ยงเฟรมเวิร์กการจำลองของบุคคลที่สามที่มีขนาดใหญ่ A2uiComponentStub.withId จะสกัดกั้นอินสแตนซ์คอมโพเนนต์ที่เฉพาะเจาะจงตามรหัส ขณะที่ A2uiComponentStub.withType จะลบล้างการแสดงผลสำหรับแคตตาล็อกทั้งประเภท

การตรวจสอบสคีมาแบบล้มเหลวอย่างรวดเร็ว

เฟรมเวิร์กการทดสอบจะบังคับใช้สัญญาโปรโตคอล A2UI แบบพร้อมกัน เมื่อ ตัวควบคุมเริ่มต้นหรืออัปเดตคอมโพเนนต์ ตัวควบคุมจะเรียกใช้ A2uiCoreSchemaValidator กับเพย์โหลดที่ระบุ หากตั้งค่าพร็อพเพอร์ตี้ที่ไม่ถูกต้อง เช่น ไม่มี ฟิลด์ที่จำเป็นหรือประเภทไม่ตรงกัน การทดสอบจะหยุดทำงานทันทีพร้อมกับ A2uiValidationException

การซิงค์โครูทีน

A2uiTestController.start จะเชื่อมต่อกับบริบทของโครูทีนทดสอบ ที่ runComposeUiTest() จัดเตรียมไว้ให้ โดยจะแยก currentCoroutineContext(), แมปลูปพื้นหลัง ไปยัง Job ที่แยกออกมา และยกเลิกตัวเองโดยอัตโนมัติเมื่อ บล็อกการทดสอบเสร็จสมบูรณ์ เพื่อป้องกันการดำเนินการทดสอบที่ค้างอยู่ waitForIdle() รอให้โคโรทีนเบื้องหลังที่รอดำเนินการทั้งหมดเสร็จสิ้น