Skip to content

Commit

Permalink
Merge pull request #2381 from DataDog/yl/compose/add-switch-sample
Browse files Browse the repository at this point in the history
RUM-6198: Add RadioButton Semantics Node Mapper
  • Loading branch information
ambushwork authored Nov 7, 2024
2 parents 0bba965 + d5be643 commit 8a06cde
Show file tree
Hide file tree
Showing 6 changed files with 384 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ internal abstract class AbstractSemanticsNodeMapper(
private val semanticsUtils: SemanticsUtils = SemanticsUtils()
) : SemanticsNodeMapper {

protected fun resolveId(semanticsNode: SemanticsNode, currentIndex: Int = 0): Long {
// Use semantics node intrinsic id as the higher endian of Long type and the index of
// the wireframe inside the node as the lower endian to generate a unique id.
return semanticsNode.id.toLong() shl SEMANTICS_ID_BIT_SHIFT + currentIndex
}

protected fun resolveBounds(semanticsNode: SemanticsNode): GlobalBounds {
return semanticsUtils.resolveInnerBounds(semanticsNode)
}
Expand Down Expand Up @@ -65,5 +71,6 @@ internal abstract class AbstractSemanticsNodeMapper(
private const val UNSPECIFIED_COLOR = 16L
private const val COMPOSE_COLOR_SHIFT = 32
private const val MAX_ALPHA = 255
private const val SEMANTICS_ID_BIT_SHIFT = 32
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter

internal class RadioButtonSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
semanticsUtils: SemanticsUtils = SemanticsUtils()
) : AbstractSemanticsNodeMapper(
colorStringFormatter,
semanticsUtils
) {
override fun map(
semanticsNode: SemanticsNode,
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
var wireframeIndex = 0
val boxWireframe = resolveBoxWireframe(semanticsNode, wireframeIndex++)
val dotWireframe = resolveDotWireframe(semanticsNode, wireframeIndex)
return SemanticsWireframe(
uiContext = null,
wireframes = listOfNotNull(boxWireframe, dotWireframe)
)
}

private fun resolveBoxWireframe(
semanticsNode: SemanticsNode,
wireframeIndex: Int
): MobileSegment.Wireframe {
val globalBounds = resolveBounds(semanticsNode)
return MobileSegment.Wireframe.ShapeWireframe(
id = resolveId(semanticsNode, wireframeIndex),
x = globalBounds.x,
y = globalBounds.y,
width = globalBounds.width,
height = globalBounds.height,
shapeStyle = MobileSegment.ShapeStyle(
cornerRadius = globalBounds.width / 2
),
border = MobileSegment.ShapeBorder(
color = DEFAULT_COLOR_BLACK,
width = BOX_BORDER_WIDTH
)
)
}

private fun resolveDotWireframe(
semanticsNode: SemanticsNode,
wireframeIndex: Int
): MobileSegment.Wireframe? {
val selected = semanticsNode.config.getOrNull(SemanticsProperties.Selected) ?: false
val globalBounds = resolveBounds(semanticsNode)
return if (selected) {
MobileSegment.Wireframe.ShapeWireframe(
id = resolveId(semanticsNode, wireframeIndex),
x = globalBounds.x + DOT_PADDING_DP,
y = globalBounds.y + DOT_PADDING_DP,
width = globalBounds.width - DOT_PADDING_DP * 2,
height = globalBounds.height - DOT_PADDING_DP * 2,
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = DEFAULT_COLOR_BLACK,
cornerRadius = (globalBounds.width - DOT_PADDING_DP * 2) / 2
)
)
} else {
null
}
}

companion object {
private const val DOT_PADDING_DP = 4
private const val DEFAULT_COLOR_BLACK = "#000000FF"
private const val BOX_BORDER_WIDTH = 1L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ internal class SemanticsWireframeMapper(
private val semanticsUtils: SemanticsUtils = SemanticsUtils(),
private val semanticsNodeMapper: Map<Role, SemanticsNodeMapper> = mapOf(
// TODO RUM-6189 Add Mappers for each Semantics Role
Role.RadioButton to RadioButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Tab to TabSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Image to ImageSemanticsNodeMapper(colorStringFormatter)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.test.elmyr.SessionReplayComposeForgeConfigurator
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.annotation.Forgery
import fr.xgouchet.elmyr.annotation.LongForgery
import fr.xgouchet.elmyr.annotation.StringForgery
import fr.xgouchet.elmyr.junit5.ForgeConfiguration
import fr.xgouchet.elmyr.junit5.ForgeExtension
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.Extensions
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.junit.jupiter.MockitoSettings
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.whenever
import org.mockito.quality.Strictness

@Extensions(
ExtendWith(MockitoExtension::class),
ExtendWith(ForgeExtension::class)
)
@MockitoSettings(strictness = Strictness.LENIENT)
@ForgeConfiguration(SessionReplayComposeForgeConfigurator::class)
internal class RadioButtonSemanticsNodeMapperTest : AbstractCompositionGroupMapperTest() {

private lateinit var testedRadioButtonSemanticsNodeMapper: RadioButtonSemanticsNodeMapper

@Mock
private lateinit var mockSemanticsConfig: SemanticsConfiguration

@Mock
private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback

@LongForgery(min = 0xffffffff)
var fakeBackgroundColor: Long = 0L

@StringForgery(regex = "#[0-9A-F]{8}")
lateinit var fakeBackgroundColorHexString: String

@Forgery
lateinit var fakeUiContext: UiContext

@BeforeEach
override fun `set up`(forge: Forge) {
super.`set up`(forge)
mockColorStringFormatter(fakeBackgroundColor, fakeBackgroundColorHexString)

testedRadioButtonSemanticsNodeMapper = RadioButtonSemanticsNodeMapper(
colorStringFormatter = mockColorStringFormatter,
semanticsUtils = mockSemanticsUtils
)
}

@Test
fun `M return the box wireframe W map {selected = false}`() {
// Given
val mockSemanticsNode = mockSemanticsNode()
whenever(mockSemanticsNode.config) doReturn mockSemanticsConfig
whenever(mockSemanticsConfig.getOrNull(SemanticsProperties.Selected)) doReturn false
whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn rectToBounds(
fakeBounds,
fakeDensity
)
// When
val actual = testedRadioButtonSemanticsNodeMapper.map(
mockSemanticsNode,
fakeUiContext,
mockAsyncJobStatusCallback
)

// Then
val expected = MobileSegment.Wireframe.ShapeWireframe(
id = fakeSemanticsId.toLong() shl 32,
x = (fakeBounds.left / fakeDensity).toLong(),
y = (fakeBounds.top / fakeDensity).toLong(),
width = (fakeBounds.size.width / fakeDensity).toLong(),
height = (fakeBounds.size.height / fakeDensity).toLong(),
shapeStyle = MobileSegment.ShapeStyle(
cornerRadius = (fakeBounds.size.width / fakeDensity).toLong() / 2
),
border = MobileSegment.ShapeBorder(
color = DEFAULT_COLOR_BLACK,
width = BOX_BORDER_WIDTH
)
)
assertThat(actual.wireframes).containsExactly(expected)
}

@Test
fun `M return the box wireframe W map {selected = true}`() {
// Given
val mockSemanticsNode = mockSemanticsNode()
whenever(mockSemanticsNode.config) doReturn mockSemanticsConfig
whenever(mockSemanticsConfig.getOrNull(SemanticsProperties.Selected)) doReturn true
whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn rectToBounds(
fakeBounds,
fakeDensity
)

// When
val actual = testedRadioButtonSemanticsNodeMapper.map(
mockSemanticsNode,
fakeUiContext,
mockAsyncJobStatusCallback
)

// Then
val boxFrame = MobileSegment.Wireframe.ShapeWireframe(
id = fakeSemanticsId.toLong() shl 32,
x = (fakeBounds.left / fakeDensity).toLong(),
y = (fakeBounds.top / fakeDensity).toLong(),
width = (fakeBounds.size.width / fakeDensity).toLong(),
height = (fakeBounds.size.height / fakeDensity).toLong(),
shapeStyle = MobileSegment.ShapeStyle(
cornerRadius = (fakeBounds.size.width / fakeDensity).toLong() / 2
),
border = MobileSegment.ShapeBorder(
color = DEFAULT_COLOR_BLACK,
width = BOX_BORDER_WIDTH
)
)

val dotFrame = MobileSegment.Wireframe.ShapeWireframe(
id = fakeSemanticsId.toLong() shl 32 + 1,
x = (fakeBounds.left / fakeDensity).toLong() + DOT_PADDING_DP,
y = (fakeBounds.top / fakeDensity).toLong() + DOT_PADDING_DP,
width = (fakeBounds.size.width / fakeDensity).toLong() - 2 * DOT_PADDING_DP,
height = (fakeBounds.size.height / fakeDensity).toLong() - 2 * DOT_PADDING_DP,
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = DEFAULT_COLOR_BLACK,
cornerRadius = ((fakeBounds.size.width / fakeDensity).toLong() - 2 * DOT_PADDING_DP) / 2
)
)
assertThat(actual.wireframes).containsAll(listOf(boxFrame, dotFrame))
}

private fun mockSemanticsNode(): SemanticsNode {
return mockSemanticsNodeWithBound {}
}

companion object {
private const val DOT_PADDING_DP = 4
private const val DEFAULT_COLOR_BLACK = "#000000FF"
private const val BOX_BORDER_WIDTH = 1L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal fun SampleSelectionScreen(
onTypographyClicked: () -> Unit,
onLegacyClicked: () -> Unit,
onImageClicked: () -> Unit,
onToggleClicked: () -> Unit,
onTabsClicked: () -> Unit
) {
Column(
Expand All @@ -46,6 +47,10 @@ internal fun SampleSelectionScreen(
text = "Image Sample",
onClick = onImageClicked
)
StyledButton(
text = "Toggle Buttons Sample",
onClick = onToggleClicked
)
StyledButton(
text = "Tabs Sample",
onClick = onTabsClicked
Expand Down Expand Up @@ -81,6 +86,9 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle
onImageClicked = {
navController.navigate(SampleScreen.Image.navigationRoute)
},
onToggleClicked = {
navController.navigate(SampleScreen.Toggle.navigationRoute)
},
onTabsClicked = {
navController.navigate(SampleScreen.Tabs.navigationRoute)
},
Expand All @@ -98,6 +106,10 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle
ImageSample()
}

composable(SampleScreen.Toggle.navigationRoute) {
ToggleSample()
}

composable(SampleScreen.Tabs.navigationRoute) {
TabsSample()
}
Expand All @@ -114,6 +126,7 @@ internal sealed class SampleScreen(
object Root : SampleScreen(COMPOSE_ROOT)
object Typography : SampleScreen("$COMPOSE_ROOT/typography")
object Image : SampleScreen("$COMPOSE_ROOT/image")
object Toggle : SampleScreen("$COMPOSE_ROOT/toggle")
object Tabs : SampleScreen("$COMPOSE_ROOT/tabs")
object Legacy : SampleScreen("$COMPOSE_ROOT/legacy")

Expand All @@ -131,6 +144,8 @@ private fun PreviewSampleSelectionScreen() {
},
onImageClicked = {
},
onToggleClicked = {
},
onTypographyClicked = {
},
onTabsClicked = {
Expand Down
Loading

0 comments on commit 8a06cde

Please sign in to comment.