Skip to content

Commit

Permalink
RUM-7216: Add Slider semantics node mapper
Browse files Browse the repository at this point in the history
  • Loading branch information
ambushwork committed Dec 17, 2024
1 parent 41f157b commit bbc6703
Show file tree
Hide file tree
Showing 6 changed files with 309 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ internal class RootSemanticsNodeMapper(
private val composeHiddenMapper: ComposeHiddenMapper = ComposeHiddenMapper(
colorStringFormatter,
semanticsUtils
),
private val sliderSemanticsNodeMapper: SliderSemanticsNodeMapper = SliderSemanticsNodeMapper(
colorStringFormatter,
semanticsUtils
)
) {

Expand Down Expand Up @@ -148,6 +152,8 @@ internal class RootSemanticsNodeMapper(
textFieldSemanticsNodeMapper
} else if (isTextNode(semanticsNode)) {
textSemanticsNodeMapper
} else if (isSliderNode(semanticsNode)) {
sliderSemanticsNodeMapper
} else {
containerSemanticsNodeMapper
}
Expand All @@ -162,6 +168,10 @@ internal class RootSemanticsNodeMapper(
return semanticsNode.config.contains(SemanticsActions.SetText)
}

private fun isSliderNode(semanticsNode: SemanticsNode): Boolean {
return semanticsUtils.getProgressBarRangeInfo(semanticsNode) != null
}

@UiThread
private fun updateTouchOverrideAreas(
semanticsNode: SemanticsNode,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* 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.unit.dp
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 SliderSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
private val semanticsUtils: SemanticsUtils = SemanticsUtils()
) : AbstractSemanticsNodeMapper(
colorStringFormatter,
semanticsUtils
) {
override fun map(
semanticsNode: SemanticsNode,
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
var index = 0
val trackWireframe = resolveTrackWireframe(parentContext, semanticsNode, index++)
val thumbWireframe = resolveThumbWireframe(parentContext, semanticsNode, index)
return SemanticsWireframe(
listOfNotNull(trackWireframe, thumbWireframe),
null
)
}

private fun resolveThumbWireframe(
parentContext: UiContext,
semanticsNode: SemanticsNode,
index: Int
): MobileSegment.Wireframe? {
val globalBounds = resolveBounds(semanticsNode)
val progressBarRangeInfo = semanticsUtils.getProgressBarRangeInfo(semanticsNode)
val progress = progressBarRangeInfo?.let {
it.current / (it.range.endInclusive - it.range.start)
}
val thumbHeight = DEFAULT_THUMB_RADIUS.value * 2 * parentContext.density
val yOffset = (globalBounds.height - thumbHeight).toLong() / 2
println("prod thumb - height: ${globalBounds.height}, thumbHeight $thumbHeight")
println("test thumb - yOffset: $yOffset")
return progress?.let {
val xOffset = progress * globalBounds.width + globalBounds.x
MobileSegment.Wireframe.ShapeWireframe(
id = resolveId(semanticsNode, index),
x = xOffset.toLong(),
y = globalBounds.y + yOffset,
width = thumbHeight.toLong(),
height = thumbHeight.toLong(),
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = DEFAULT_COLOR,
cornerRadius = thumbHeight / 2
)
)
}
}

private fun resolveTrackWireframe(
parentContext: UiContext,
semanticsNode: SemanticsNode,
index: Int
): MobileSegment.Wireframe {
val globalBounds = resolveBounds(semanticsNode)
val trackHeight = DEFAULT_TRACK_HEIGHT.value * parentContext.density
val yOffset = (globalBounds.height - trackHeight).toLong() / 2
return MobileSegment.Wireframe.ShapeWireframe(
id = resolveId(semanticsNode, index),
x = globalBounds.x,
y = globalBounds.y + yOffset,
width = globalBounds.width,
height = trackHeight.toLong(),
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = DEFAULT_COLOR,
cornerRadius = trackHeight / 2
)
)
}

companion object {
// TODO RUM-7467: Use contrast color of parent color
private const val DEFAULT_COLOR = "#000000FF"
private val DEFAULT_THUMB_RADIUS = 4.dp
private val DEFAULT_TRACK_HEIGHT = 4.dp
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.vector.VectorPainter
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.TextLayoutInput
import androidx.compose.ui.text.TextLayoutResult
Expand Down Expand Up @@ -277,4 +279,8 @@ internal class SemanticsUtils(private val reflectionUtils: ReflectionUtils = Ref
internal fun getInteropView(semanticsNode: SemanticsNode): View? {
return reflectionUtils.getInteropView(semanticsNode)
}

internal fun getProgressBarRangeInfo(semanticsNode: SemanticsNode): ProgressBarRangeInfo? {
return semanticsNode.config.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import android.view.View
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsNode
Expand Down Expand Up @@ -74,6 +75,9 @@ class RootSemanticsNodeMapperTest {
@Mock
private lateinit var mockComposeHiddenMapper: ComposeHiddenMapper

@Mock
private lateinit var mockSliderSemanticsNodeMapper: SliderSemanticsNodeMapper

@Mock
private lateinit var mockSemanticsConfiguration: SemanticsConfiguration

Expand All @@ -95,7 +99,8 @@ class RootSemanticsNodeMapperTest {
),
textSemanticsNodeMapper = mockTextSemanticsNodeMapper,
containerSemanticsNodeMapper = mockContainerSemanticsNodeMapper,
composeHiddenMapper = mockComposeHiddenMapper
composeHiddenMapper = mockComposeHiddenMapper,
sliderSemanticsNodeMapper = mockSliderSemanticsNodeMapper
)
}

Expand Down Expand Up @@ -204,6 +209,29 @@ class RootSemanticsNodeMapperTest {
)
}

@Test
fun `M use SliderSemanticsNodeMapper W map { isSliderNode }`() {
// Given
val mockSemanticsNode = mockSemanticsNode(null)
val mockProgressBarRangeInfo = mock<ProgressBarRangeInfo>()
whenever(mockSemanticsUtils.getProgressBarRangeInfo(mockSemanticsNode)) doReturn mockProgressBarRangeInfo

// When
testedRootSemanticsNodeMapper.createComposeWireframes(
mockSemanticsNode,
fakeMappingContext.systemInformation.screenDensity,
fakeMappingContext,
mockAsyncJobStatusCallback
)

// Then
verify(mockSliderSemanticsNodeMapper).map(
eq(mockSemanticsNode),
any(),
eq(mockAsyncJobStatusCallback)
)
}

@Test
fun `M use ComposeHideMapper W node is hidden`(forge: Forge) {
// Given
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* 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.ProgressBarRangeInfo
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.unit.dp
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo
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.FloatForgery
import fr.xgouchet.elmyr.annotation.Forgery
import fr.xgouchet.elmyr.annotation.LongForgery
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.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 SliderSemanticsNodeMapperNodeMapperTest : AbstractSemanticsNodeMapperTest() {

private lateinit var testedSliderSemanticsNodeMapper: SliderSemanticsNodeMapper

@Mock
private lateinit var mockSemanticsNode: SemanticsNode

@Mock
private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback

@Mock
private lateinit var mockProgressBarRangeInfo: ProgressBarRangeInfo

@Mock
private lateinit var mockRange: ClosedFloatingPointRange<Float>

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

@FloatForgery
var fakeCornerRadius: Float = 0f

@FloatForgery
var fakeStart: Float = 0f

@FloatForgery
var fakeCurrent: Float = 0f

@FloatForgery
var fakeEndInclusive: Float = 0f

@Forgery
lateinit var fakeUiContext: UiContext

@BeforeEach
override fun `set up`(forge: Forge) {
super.`set up`(forge)
testedSliderSemanticsNodeMapper = SliderSemanticsNodeMapper(
colorStringFormatter = mockColorStringFormatter,
semanticsUtils = mockSemanticsUtils
)
}

private fun mockSemanticsNode(): SemanticsNode {
return mockSemanticsNodeWithBound {
whenever(mockSemanticsNode.layoutInfo).doReturn(mockLayoutInfo)
}
}

fun `M return the correct wireframe W map`() {
// Given
val mockSemanticsNode = mockSemanticsNode()
val fakeGlobalBounds = rectToBounds(fakeBounds, fakeUiContext.density)
val fakeBackgroundInfo = BackgroundInfo(
globalBounds = fakeGlobalBounds,
color = fakeBackgroundColor,
cornerRadius = fakeCornerRadius
)
whenever(mockSemanticsUtils.resolveInnerBounds(mockSemanticsNode)) doReturn fakeGlobalBounds
whenever(mockSemanticsUtils.resolveBackgroundInfo(mockSemanticsNode)) doReturn listOf(
fakeBackgroundInfo
)
whenever(mockSemanticsUtils.getProgressBarRangeInfo(mockSemanticsNode)) doReturn
mockProgressBarRangeInfo
whenever(mockProgressBarRangeInfo.range) doReturn mockRange
whenever(mockProgressBarRangeInfo.current) doReturn fakeCurrent
whenever(mockRange.start) doReturn fakeStart
whenever(mockRange.endInclusive) doReturn fakeEndInclusive

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

// Then
val trackHeight = DEFAULT_TRACK_HEIGHT.value * fakeUiContext.density
val yTrackOffset = (fakeGlobalBounds.height - trackHeight).toLong() / 2
val fakeProgress = fakeCurrent / (fakeEndInclusive - fakeStart)
val xOffset = fakeProgress * fakeGlobalBounds.width + fakeGlobalBounds.x
val thumbHeight = DEFAULT_THUMB_RADIUS.value * 2 * fakeUiContext.density
val expectedTrackWireframe = MobileSegment.Wireframe.ShapeWireframe(
id = (fakeSemanticsId.toLong() shl 32) + 0,
x = fakeGlobalBounds.x,
y = fakeGlobalBounds.y + yTrackOffset,
width = fakeGlobalBounds.width,
height = trackHeight.toLong(),
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = DEFAULT_COLOR,
cornerRadius = trackHeight / 2
)
)
val yThumbOffset = (fakeGlobalBounds.height - thumbHeight).toLong() / 2
println("test thumb - height: ${fakeGlobalBounds.height}, thumbHeight $thumbHeight")
println("test thumb - yOffset: $yThumbOffset")
val expectedThumbWireframe = MobileSegment.Wireframe.ShapeWireframe(
id = (fakeSemanticsId.toLong() shl 32) + 1,
x = xOffset.toLong(),
y = fakeGlobalBounds.y + yThumbOffset,
width = thumbHeight.toLong(),
height = thumbHeight.toLong(),
shapeStyle = MobileSegment.ShapeStyle(
backgroundColor = DEFAULT_COLOR,
cornerRadius = thumbHeight / 2
)
)
assertThat(actual.wireframes).contains(expectedThumbWireframe, expectedTrackWireframe)
}

companion object {
private const val DEFAULT_COLOR = "#000000FF"
private val DEFAULT_THUMB_RADIUS = 4.dp
private val DEFAULT_TRACK_HEIGHT = 4.dp
}
}
Loading

0 comments on commit bbc6703

Please sign in to comment.