协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
asserting-bounds-and-dimensions【开发与工程】Use this skill to verify Compose layout measurements from a UI test using `assertWidthIsEqualTo`, `assertHeightIsEqualTo`, `assertWidthIsAtLeast`, `assertHeightIsAtLeast`, `assertTouchWidthIsEqualTo`, `assertTouchHeightIsEqualTo`, `assertPositionInRootIsEqualTo`, `assertTopPositionInRootIsEqualTo`, `assertLeftPositionInRootIsEqualTo`, plus read helpe…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
name: asserting-bounds-and-dimensions
description: Use this skill to verify Compose layout measurements from a UI test using assertWidthIsEqualTo, assertHeightIsEqualTo, assertWidthIsAtLeast, assertHeightIsAtLeast, assertTouchWidthIsEqualTo, assertTouchHeightIsEqualTo, assertPositionInRootIsEqualTo, assertTopPositionInRootIsEqualTo, assertLeftPositionInRootIsEqualTo, plus read helpers getUnclippedBoundsInRoot, getBoundsInRoot, getAlignmentLinePosition, getFirstLinkBounds, and the underlying Dp.assertIsEqualTo(expected, subject, tolerance = Dp(.5f)). Covers the half-dp default tolerance, the unclipped vs clipped distinction, the canonical "compute padding from two unclipped rects" pattern, and minimum-touch-target assertions like assertHeightIsAtLeast(MinHeight + 1.dp). Use when the developer wants to assert sizes, padding, alignment, position in dp, or asks about getUnclippedBoundsInRoot, DpRect, touch-target size, or compares widths in pixels. If the developer is comparing layout dimensions from a test, use this skill.
license: Apache-2.0. See LICENSE for complete terms.
metadata:
author: Jaewoong Eum (skydoves)
keywords:
Layout assertions belong in dp, run with a half-dp tolerance, and most of the interesting checks (padding, gap, alignment) are subtractions between two getUnclippedBoundsInRoot() rectangles. This skill picks the right size/position assertion, explains clipped vs unclipped, and shows the canonical "compute padding from two rects" pattern lifted directly from material3/ButtonTest.kt.
(24.dp, 12.dp).ChipDefaults.MinHeight + 1.dp).assertWidthIsEqualTo, getUnclippedBoundsInRoot, DpRect, getAlignmentLinePosition, getFirstLinkBounds../asserting-node-state-and-text/SKILL.md.assertIsDisplayed() is enough; bounds math adds friction without value.../../synchronization/testing-animations-deterministically/SKILL.md.ComposeTestRule / ComposeUiTest. See ../../setup/configuring-test-dependencies/SKILL.md.../../synchronization/controlling-the-test-clock/SKILL.md.touchBoundsInRoot is meaningful. 1. Pick the assertion by question type. All APIs live in commonMain/.../BoundsAssertions.kt.
| Question | API | File:line |
|---|---|---|
| Is the layout exactly W dp wide? | assertWidthIsEqualTo(expectedWidth: Dp) | BoundsAssertions.kt:44-46 |
| Is the layout exactly H dp tall? | assertHeightIsEqualTo(expectedHeight: Dp) | BoundsAssertions.kt:53-55 |
| At least W wide? | assertWidthIsAtLeast(expectedMinWidth: Dp) | BoundsAssertions.kt:85-87 |
| At least H tall? | assertHeightIsAtLeast(expectedMinHeight: Dp) | BoundsAssertions.kt:95-99 |
| Touch-target width? | assertTouchWidthIsEqualTo(expectedWidth: Dp) | BoundsAssertions.kt:62-66 |
| Touch-target height? | assertTouchHeightIsEqualTo(expectedHeight: Dp) | BoundsAssertions.kt:73-77 |
| Exact position in root? | assertPositionInRootIsEqualTo(left: Dp, top: Dp) | BoundsAssertions.kt:109-117 |
| Top position only? | assertTopPositionInRootIsEqualTo(top: Dp) | BoundsAssertions.kt:126-130 |
| Left position only? | assertLeftPositionInRootIsEqualTo(left: Dp) | BoundsAssertions.kt:139-143 |
| Read full unclipped bounds | getUnclippedBoundsInRoot(): DpRect | BoundsAssertions.kt:148-152 |
| Read clipped bounds | getBoundsInRoot(): DpRect | BoundsAssertions.kt:158-165 |
| Alignment line in dp | getAlignmentLinePosition(line: AlignmentLine): Dp | BoundsAssertions.kt:171-180 |
Bounds of a LinkAnnotation in a Text | getFirstLinkBounds(predicate) | BoundsAssertions.kt:196-248 |
Compare any two Dp values | Dp.assertIsEqualTo(expected, subject, tolerance = Dp(.5f)) | BoundsAssertions.kt:319-324 |
2. Use unclipped bounds for layout math; clipped bounds for "what the user sees". getUnclippedBoundsInRoot() returns the laid-out rectangle ignoring viewport clipping (BoundsAssertions.kt:148-152 → unclippedBoundsInRoot private, BoundsAssertions.kt:284-291). getBoundsInRoot() clips to the viewport (BoundsAssertions.kt:158-165). Padding/spacing math uses unclipped; partial-visibility checks use clipped.
3. Tolerance is half a dp by default. Dp.assertIsEqualTo(expected, subject, tolerance = Dp(.5f)) (BoundsAssertions.kt:319) accepts deviations up to 0.5 dp because layout rounding introduces sub-dp drift. Override only when stricter precision is justified by an explicit measurement contract.
4. Compute padding by subtracting two unclipped rects, then call Dp.assertIsEqualTo. This is the canonical material3 pattern (material3/.../ButtonTest.kt:213-225):
val buttonBounds = rule.onNodeWithTag(ButtonTestTag).getUnclippedBoundsInRoot()
val textBounds = rule.onNodeWithTag(TextTestTag).getUnclippedBoundsInRoot()
(textBounds.left - buttonBounds.left).assertIsEqualTo(
24.dp,
"padding between the start of the button and the start of the text.",
)
(buttonBounds.right - textBounds.right).assertIsEqualTo(
24.dp,
"padding between the end of the text and the end of the button.",
)
buttonBounds.height.assertIsEqualTo(ButtonDefaults.MinHeight, "height of button.")
The subject string lands in the failure message: "Actual padding between the start of the button and the start of the text. is 22.dp, expected 24.dp (tolerance: .5.dp)".
5. For minimum-size contracts, use assertHeightIsAtLeast / assertWidthIsAtLeast. Useful when a composable should never be smaller than a constant, even at large font scale. Example from material/ChipTest.kt:226-233:
rule.setMaterialContent { Chip(onClick = {}) { Text(text = "Test chip", fontSize = 50.sp) } }
rule.onNode(hasClickAction()).assertHeightIsAtLeast(ChipDefaults.MinHeight + 1.dp)
6. Use touch bounds when verifying tap-target accessibility. assertTouchWidthIsEqualTo / assertTouchHeightIsEqualTo reads node.touchBoundsInRoot (BoundsAssertions.kt:270-282), which can extend past the visual bounds when the composable applies Modifier.minimumInteractiveComponentSize() or similar. Visual bounds use getUnclippedBoundsInRoot; touch bounds answer "where will a click land".
// WRONG
@Test
fun submit_isMin48dpTall() {
rule.setContent { CheckoutScreen() }
val node = rule.onNodeWithTag(SubmitTag).fetchSemanticsNode()
val heightPx = node.size.height
assert(heightPx >= 48 * Resources.getSystem().displayMetrics.density)
}
// WRONG because: pixel-typed and density-dependent. Reads outside the framework's tolerance
// model. Failure prints raw integers, no node dump, no subject label.
// RIGHT
@Test
fun submit_isMin48dpTall() {
rule.setContent { CheckoutScreen() }
rule.onNodeWithTag(SubmitTag).assertHeightIsAtLeast(48.dp)
}
@Test
fun button_text_has24dpPadding() {
rule.setContent {
Button(onClick = {}, modifier = Modifier.testTag(ButtonTestTag)) {
Text("Submit", modifier = Modifier.testTag(TextTestTag).semantics(mergeDescendants = true) {})
}
}
val buttonBounds = rule.onNodeWithTag(ButtonTestTag).getUnclippedBoundsInRoot()
val textBounds = rule.onNodeWithTag(TextTestTag).getUnclippedBoundsInRoot()
(textBounds.left - buttonBounds.left).assertIsEqualTo(24.dp, "start padding")
(buttonBounds.right - textBounds.right).assertIsEqualTo(24.dp, "end padding")
}
The merge bypass on the inner Text is the same trick used by material3/ButtonTest.kt:202-226 to keep the inner Text addressable from the merged tree.
assertPositionInRootIsEqualTo for absolute placement// "the close button sits at (320.dp, 0.dp) in the root"
rule.onNodeWithTag(CloseButtonTag)
.assertPositionInRootIsEqualTo(expectedLeft = 320.dp, expectedTop = 0.dp)
If only one axis matters, use assertLeftPositionInRootIsEqualTo / assertTopPositionInRootIsEqualTo to avoid coupling the test to layout decisions on the other axis.
val baselineDp = rule.onNodeWithTag(LabelTag)
.getAlignmentLinePosition(FirstBaseline)
require(!baselineDp.isUnspecified) { "Label has no first baseline" }
baselineDp.assertIsEqualTo(20.dp, "first baseline of label")
getAlignmentLinePosition returns Dp.Unspecified when the alignment line is not provided (BoundsAssertions.kt:172-179). Always check isUnspecified before comparing.
// Most tests want the default ½ dp tolerance:
rule.onNodeWithTag(IconTag).getUnclippedBoundsInRoot().width.assertIsEqualTo(24.dp, "icon width")
// Stricter tolerance when measuring a hand-aligned constant:
val width = rule.onNodeWithTag(IconTag).getUnclippedBoundsInRoot().width
width.assertIsEqualTo(expected = 24.dp, subject = "icon width", tolerance = 0.1.dp)
val unclipped = rule.onNodeWithTag(BannerTag).getUnclippedBoundsInRoot()
val clipped = rule.onNodeWithTag(BannerTag).getBoundsInRoot()
// Banner laid out 200 dp tall but only 80 dp visible (rest clipped by parent):
unclipped.height.assertIsEqualTo(200.dp, "banner intrinsic height")
clipped.height.assertIsEqualTo(80.dp, "banner visible height")
For "is any of it visible", prefer assertIsDisplayed() — see ./asserting-node-state-and-text/SKILL.md.
assertWidthIsEqualTo / assertHeightIsEqualTo / assertPositionInRootIsEqualTo. MUST NOT read fetchSemanticsNode().size.width and compare pixels.getUnclippedBoundsInRoot() for padding / gap / alignment math; MUST use getBoundsInRoot() only when the contract is "what the user sees after clipping".subject string to Dp.assertIsEqualTo so the failure message identifies which measurement failed.assertHeightIsAtLeast(MinHeight + 1.dp) over assertHeightIsEqualTo(MinHeight + N.dp) when the goal is "the layout grows past the minimum at large font scales".mainClock.autoAdvance = false and step deterministically before reading bounds. Skydoves hot take #3.node.size.width / node.size.height reads remain. All dimension checks use the typed assert*IsEqualTo / Dp.assertIsEqualTo.getUnclippedBoundsInRoot; clipped reads only appear with a comment explaining why.Dp.assertIsEqualTo(...) passes a non-empty subject string.assertTouchWidthIsEqualTo / assertTouchHeightIsEqualTo rather than visual bounds when verifying accessibility constraints../gradlew :app:connectedDebugAndroidTest passes; failure messages identify the specific failed measurement by subject.compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/BoundsAssertions.kt — assertWidthIsEqualTo, assertHeightIsAtLeast, assertPositionInRootIsEqualTo, getUnclippedBoundsInRoot, getBoundsInRoot, getAlignmentLinePosition, getFirstLinkBounds, Dp.assertIsEqualTo (default tolerance ½ dp).compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/ButtonTest.kt:202-226 — canonical "subtract two unclipped rects" padding test.compose/material/material/src/androidDeviceTest/kotlin/androidx/compose/material/ChipTest.kt:226-233 — assertHeightIsAtLeast(MinHeight + 1.dp) for minimum-touch contracts.