diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt index 9bb8bf167..7ae53ae2f 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt @@ -1,11 +1,18 @@ +/* + * 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.reactnative.sessionreplay.mappers import ReactViewBackgroundDrawableUtils -import android.view.View +import android.view.ViewGroup import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.mapper.BaseWireframeMapper +import com.datadog.android.sessionreplay.recorder.mapper.TraverseAllChildrenMapper import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.DefaultColorStringFormatter import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver @@ -17,7 +24,7 @@ import com.datadog.reactnative.sessionreplay.utils.DrawableUtils import com.datadog.reactnative.sessionreplay.views.DdPrivacyView import java.util.Collections -internal open class SvgViewMapper( +internal open class SvgViewMapper( private val internalCallback: ReactNativeInternalCallback, private val drawableUtils: DrawableUtils = ReactViewBackgroundDrawableUtils() @@ -26,7 +33,7 @@ internal open class SvgViewMapper( colorStringFormatter = DefaultColorStringFormatter, viewBoundsResolver = DefaultViewBoundsResolver, drawableToColorMapper = DrawableToColorMapper.getDefault() -) { +), TraverseAllChildrenMapper { private val queuedResourceIds = Collections.synchronizedSet(HashSet()) @Suppress("LongMethod", "ComplexMethod") @@ -53,7 +60,17 @@ internal open class SvgViewMapper( val wireframes = mutableListOf() if (view is DdPrivacyView) { - val hash = view.attributes?.get("hash") ?: return wireframes + val hash = view.attributes?.get("hash") ?: return listOf( + MobileSegment.Wireframe.ShapeWireframe( + resolveViewId(view), + viewGlobalBounds.x, + viewGlobalBounds.y, + viewGlobalBounds.width, + viewGlobalBounds.height, + shapeStyle = shapeStyle, + border = border + ) + ) val width = view.attributes?.get("width") val height = view.attributes?.get("height") @@ -89,8 +106,9 @@ internal open class SvgViewMapper( border = border )) + val imageWireframeId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, "svg") ?: return wireframes val imgWireframe = MobileSegment.Wireframe.ImageWireframe( - resolveViewId(subView), + imageWireframeId, imageBounds.x, imageBounds.y, imageBounds.width, diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt new file mode 100644 index 000000000..97b01be5e --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt @@ -0,0 +1,189 @@ +/* + * 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.reactnative.sessionreplay.mappers + +import android.view.View +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.MappingContext +import com.datadog.android.sessionreplay.recorder.SystemInformation +import com.datadog.android.sessionreplay.recorder.mapper.TraverseAllChildrenMapper +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.reactnative.sessionreplay.ReactNativeInternalCallback +import com.datadog.reactnative.sessionreplay.utils.DrawableUtils +import com.datadog.reactnative.sessionreplay.views.DdPrivacyView +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.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class SvgViewMapperTest { + + private lateinit var testedMapper: SvgViewMapper + + @Mock + private lateinit var mockDrawableUtils: DrawableUtils + + @Mock + private lateinit var mockInternalCallback: ReactNativeInternalCallback + + @Mock + private lateinit var mockDdPrivacyView: DdPrivacyView + + @Mock + private lateinit var mockChildView: View + + @Mock + private lateinit var mockMappingContext: MappingContext + + @Mock + private lateinit var mockSystemInformation: SystemInformation + + @Mock + private lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + + @Mock + private lateinit var mockInternalLogger: InternalLogger + + @BeforeEach + fun `set up`() { + whenever(mockMappingContext.systemInformation).thenReturn(mockSystemInformation) + whenever(mockSystemInformation.screenDensity).thenReturn(0f) + whenever(mockDrawableUtils.getReactBackgroundFromDrawable(null)).thenReturn(null) + + testedMapper = SvgViewMapper( + internalCallback = mockInternalCallback, + drawableUtils = mockDrawableUtils + ) + } + + @Test + fun `M implement TraverseAllChildrenMapper W class declaration`() { + assertThat(testedMapper).isInstanceOf(TraverseAllChildrenMapper::class.java) + } + + @Test + fun `M return ShapeWireframe W map() { DdPrivacyView without attributes }`() { + // Given + whenever(mockDdPrivacyView.attributes).thenReturn(null) + + // When + val result = testedMapper.map( + view = mockDdPrivacyView, + mappingContext = mockMappingContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback, + internalLogger = mockInternalLogger + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result[0]).isInstanceOf(MobileSegment.Wireframe.ShapeWireframe::class.java) + } + + @Test + fun `M return ShapeWireframe W map() { DdPrivacyView with empty attributes map }`() { + // Given + whenever(mockDdPrivacyView.attributes).thenReturn(emptyMap()) + + // When + val result = testedMapper.map( + view = mockDdPrivacyView, + mappingContext = mockMappingContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback, + internalLogger = mockInternalLogger + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result[0]).isInstanceOf(MobileSegment.Wireframe.ShapeWireframe::class.java) + } + + @Test + fun `M return ShapeWireframe and ImageWireframe W map() { DdPrivacyView with SVG hash }`() { + // Given + val hash = "svg-resource-hash" + val svgBytes = "".toByteArray(Charsets.UTF_8) + whenever(mockDdPrivacyView.attributes).thenReturn( + mapOf("hash" to hash, "width" to "32", "height" to "32") + ) + whenever(mockDdPrivacyView.getChildAt(0)).thenReturn(mockChildView) + whenever(mockInternalCallback.getEntryData(hash)).thenReturn(svgBytes) + + // When + val result = testedMapper.map( + view = mockDdPrivacyView, + mappingContext = mockMappingContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback, + internalLogger = mockInternalLogger + ) + + // Then + assertThat(result).hasSize(2) + assertThat(result[0]).isInstanceOf(MobileSegment.Wireframe.ShapeWireframe::class.java) + assertThat(result[1]).isInstanceOf(MobileSegment.Wireframe.ImageWireframe::class.java) + val shapeWireframe = result[0] as MobileSegment.Wireframe.ShapeWireframe + val imageWireframe = result[1] as MobileSegment.Wireframe.ImageWireframe + assertThat(imageWireframe.resourceId).isEqualTo(hash) + assertThat(imageWireframe.mimeType).isEqualTo("svg+xml") + // ImageWireframe must have a distinct ID from the container and from the child view, + // since TraverseAllChildrenMapper causes the child to be independently mapped. + assertThat(imageWireframe.id).isNotEqualTo(shapeWireframe.id) + assertThat(imageWireframe.id).isNotEqualTo(System.identityHashCode(mockChildView).toLong()) + } + + @Test + fun `M return empty list W map() { DdPrivacyView with hash but no entry data }`() { + // Given + val hash = "missing-entry-hash" + whenever(mockDdPrivacyView.attributes).thenReturn(mapOf("hash" to hash)) + whenever(mockInternalCallback.getEntryData(hash)).thenReturn(null) + + // When + val result = testedMapper.map( + view = mockDdPrivacyView, + mappingContext = mockMappingContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback, + internalLogger = mockInternalLogger + ) + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `M return empty list W map() { DdPrivacyView with hash but no child view }`() { + // Given + val hash = "no-child-hash" + val svgBytes = "".toByteArray(Charsets.UTF_8) + whenever(mockDdPrivacyView.attributes).thenReturn(mapOf("hash" to hash)) + whenever(mockInternalCallback.getEntryData(hash)).thenReturn(svgBytes) + whenever(mockDdPrivacyView.getChildAt(0)).thenReturn(null) + + // When + val result = testedMapper.map( + view = mockDdPrivacyView, + mappingContext = mockMappingContext, + asyncJobStatusCallback = mockAsyncJobStatusCallback, + internalLogger = mockInternalLogger + ) + + // Then + assertThat(result).isEmpty() + } +}