-
Notifications
You must be signed in to change notification settings - Fork 61
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
RUM-7216: Add Slider semantics node mapper
- Loading branch information
1 parent
41f157b
commit bbc6703
Showing
6 changed files
with
309 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
96 changes: 96 additions & 0 deletions
96
...dog/android/sessionreplay/compose/internal/mappers/semantics/SliderSemanticsNodeMapper.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
155 changes: 155 additions & 0 deletions
155
...ssionreplay/compose/internal/mappers/semantics/SliderSemanticsNodeMapperNodeMapperTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
Oops, something went wrong.