Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RUM-6200: Add Tab semantics mapper #2378

Merged
merged 1 commit into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.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,63 @@
/*
* 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 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 TabSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
private val semanticsUtils: SemanticsUtils = SemanticsUtils()
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {

override fun map(
semanticsNode: SemanticsNode,
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
val parentFrames = resolveParentColor(semanticsNode)
return SemanticsWireframe(
wireframes = parentFrames,
uiContext = parentContext
)
}

private fun resolveParentColor(semanticsNode: SemanticsNode): List<MobileSegment.Wireframe> {
val globalBounds = resolveBounds(semanticsNode)

// TODO RUM-7082: Consider use `UiContext` to pass the color information
var parentColor = semanticsNode.parent?.let { parent ->
semanticsUtils.resolveBackgroundColor(parent)?.let {
convertColor(it)
}
}

// If parent color is not specified, it may be in the grandparent modifier info.
if (parentColor == null) {
parentColor = semanticsNode.parent?.parent?.let { grandParentNode ->
semanticsUtils.resolveBackgroundColor(grandParentNode)?.let {
convertColor(it)
}
}
}

val shapeStyle = MobileSegment.ShapeStyle(backgroundColor = parentColor)
return MobileSegment.Wireframe.ShapeWireframe(
id = semanticsNode.id.toLong(),
x = globalBounds.x,
y = globalBounds.y,
width = globalBounds.width,
height = globalBounds.height,
shapeStyle = shapeStyle
).let { listOf(it) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ internal class SemanticsUtils {
return backgroundInfoList
}

internal fun resolveBackgroundColor(semanticsNode: SemanticsNode): Long? {
val backgroundModifierInfo =
semanticsNode.layoutInfo.getModifierInfo().firstOrNull { modifierInfo ->
ComposeReflection.BackgroundElementClass?.isInstance(modifierInfo.modifier) == true
}
return backgroundModifierInfo?.let {
ComposeReflection.ColorField?.getSafe(it.modifier) as? Long
}
}

internal fun resolveBackgroundInfoId(backgroundInfo: BackgroundInfo): Long {
return System.identityHashCode(backgroundInfo).toLong()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* 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 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 TabSemanticsNodeMapperTest : AbstractCompositionGroupMapperTest() {

private lateinit var testedTabSemanticsNodeMapper: TabSemanticsNodeMapper

@Mock
private lateinit var mockSemanticsNode: SemanticsNode

@Mock
private lateinit var mockParentNode: SemanticsNode

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

testedTabSemanticsNodeMapper = TabSemanticsNodeMapper(
colorStringFormatter = mockColorStringFormatter,
semanticsUtils = mockSemanticsUtils
)
}

@Test
fun `M return the correct wireframe W map`() {
// Given
val mockSemanticsNode = mockSemanticsNode()
whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn rectToBounds(
fakeBounds,
fakeDensity
)
whenever(mockSemanticsUtils.resolveBackgroundColor(mockParentNode)) doReturn fakeBackgroundColor
whenever(mockSemanticsNode.parent).doReturn(mockParentNode)
// When
val actual = testedTabSemanticsNodeMapper.map(
mockSemanticsNode,
fakeUiContext,
mockAsyncJobStatusCallback
)

// Then
val expected = MobileSegment.Wireframe.ShapeWireframe(
id = fakeSemanticsId.toLong(),
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(
backgroundColor = fakeBackgroundColorHexString
)
)
assertThat(actual.wireframes).contains(expected)
}

private fun mockSemanticsNode(): SemanticsNode {
return mockSemanticsNodeWithBound {
whenever(mockSemanticsNode.layoutInfo).doReturn(mockLayoutInfo)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import androidx.navigation.compose.composable
internal fun SampleSelectionScreen(
onTypographyClicked: () -> Unit,
onLegacyClicked: () -> Unit,
onImageClicked: () -> Unit
onImageClicked: () -> Unit,
onTabsClicked: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize(),
Expand All @@ -45,6 +46,10 @@ internal fun SampleSelectionScreen(
text = "Image Sample",
onClick = onImageClicked
)
StyledButton(
text = "Tabs Sample",
onClick = onTabsClicked
)
StyledButton(
text = "Legacy Sample",
onClick = onLegacyClicked
Expand Down Expand Up @@ -76,6 +81,9 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle
onImageClicked = {
navController.navigate(SampleScreen.Image.navigationRoute)
},
onTabsClicked = {
navController.navigate(SampleScreen.Tabs.navigationRoute)
},
onLegacyClicked = {
navController.navigate(SampleScreen.Legacy.navigationRoute)
}
Expand All @@ -90,6 +98,10 @@ internal fun NavGraphBuilder.selectionNavigation(navController: NavHostControlle
ImageSample()
}

composable(SampleScreen.Tabs.navigationRoute) {
TabsSample()
}

activity(SampleScreen.Legacy.navigationRoute) {
activityClass = LegacyComposeActivity::class
}
Expand All @@ -102,6 +114,7 @@ internal sealed class SampleScreen(
object Root : SampleScreen(COMPOSE_ROOT)
object Typography : SampleScreen("$COMPOSE_ROOT/typography")
object Image : SampleScreen("$COMPOSE_ROOT/image")
object Tabs : SampleScreen("$COMPOSE_ROOT/tabs")
object Legacy : SampleScreen("$COMPOSE_ROOT/legacy")

companion object {
Expand All @@ -119,6 +132,8 @@ private fun PreviewSampleSelectionScreen() {
onImageClicked = {
},
onTypographyClicked = {
},
onTabsClicked = {
}
)
}
Loading