Merge pull request #2433 from element-hq/feature/bma/testLogoutDialog
Test direct logout dialog and RoomDetailsView
This commit is contained in:
commit
d1023dd1a3
24 changed files with 674 additions and 116 deletions
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.api.direct
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
open class DirectLogoutStateProvider : PreviewParameterProvider<DirectLogoutState> {
|
||||
override val values: Sequence<DirectLogoutState>
|
||||
get() = sequenceOf(
|
||||
aDirectLogoutState(),
|
||||
aDirectLogoutState(logoutAction = AsyncAction.Confirming),
|
||||
aDirectLogoutState(logoutAction = AsyncAction.Loading),
|
||||
aDirectLogoutState(logoutAction = AsyncAction.Failure(Exception("Error"))),
|
||||
aDirectLogoutState(logoutAction = AsyncAction.Success("success")),
|
||||
)
|
||||
}
|
||||
|
||||
fun aDirectLogoutState(
|
||||
canDoDirectSignOut: Boolean = true,
|
||||
logoutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
|
||||
eventSink: (DirectLogoutEvents) -> Unit = {},
|
||||
) = DirectLogoutState(
|
||||
canDoDirectSignOut = canDoDirectSignOut,
|
||||
logoutAction = logoutAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
@ -17,11 +17,15 @@
|
|||
package io.element.android.features.logout.impl.direct
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutEvents
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutStateProvider
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutView
|
||||
import io.element.android.features.logout.impl.ui.LogoutActionDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -50,3 +54,14 @@ class DefaultDirectLogoutView @Inject constructor() : DirectLogoutView {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun DefaultDirectLogoutViewPreview(
|
||||
@PreviewParameter(DirectLogoutStateProvider::class) state: DirectLogoutState,
|
||||
) = ElementPreview {
|
||||
DefaultDirectLogoutView().Render(
|
||||
state = state,
|
||||
onSuccessLogout = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.logout.impl
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
|
@ -32,6 +33,7 @@ import io.element.android.tests.testutils.pressBack
|
|||
import io.element.android.tests.testutils.pressTag
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
|
|
@ -41,16 +43,11 @@ class LogoutViewTest {
|
|||
@Test
|
||||
fun `clicking on logout sends a LogoutEvents`() {
|
||||
val eventsRecorder = EventsRecorder<LogoutEvents>()
|
||||
rule.setContent {
|
||||
LogoutView(
|
||||
aLogoutState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
|
||||
onBackClicked = EnsureNeverCalled(),
|
||||
onSuccessLogout = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
rule.setLogoutView(
|
||||
aLogoutState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_signout)
|
||||
eventsRecorder.assertSingle(LogoutEvents.Logout(false))
|
||||
}
|
||||
|
|
@ -58,17 +55,12 @@ class LogoutViewTest {
|
|||
@Test
|
||||
fun `confirming logout sends a LogoutEvents`() {
|
||||
val eventsRecorder = EventsRecorder<LogoutEvents>()
|
||||
rule.setContent {
|
||||
LogoutView(
|
||||
aLogoutState(
|
||||
logoutAction = AsyncAction.Confirming,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
|
||||
onBackClicked = EnsureNeverCalled(),
|
||||
onSuccessLogout = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
rule.setLogoutView(
|
||||
aLogoutState(
|
||||
logoutAction = AsyncAction.Confirming,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressTag(TestTags.dialogPositive.value)
|
||||
eventsRecorder.assertSingle(LogoutEvents.Logout(false))
|
||||
}
|
||||
|
|
@ -77,16 +69,12 @@ class LogoutViewTest {
|
|||
fun `clicking on back invoke back callback`() {
|
||||
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setContent {
|
||||
LogoutView(
|
||||
aLogoutState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
|
||||
onBackClicked = callback,
|
||||
onSuccessLogout = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
rule.setLogoutView(
|
||||
aLogoutState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onBackClicked = callback,
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
|
@ -94,17 +82,12 @@ class LogoutViewTest {
|
|||
@Test
|
||||
fun `clicking on confirm after error sends a LogoutEvents`() {
|
||||
val eventsRecorder = EventsRecorder<LogoutEvents>()
|
||||
rule.setContent {
|
||||
LogoutView(
|
||||
aLogoutState(
|
||||
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
|
||||
onBackClicked = EnsureNeverCalled(),
|
||||
onSuccessLogout = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
rule.setLogoutView(
|
||||
aLogoutState(
|
||||
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_signout_anyway)
|
||||
eventsRecorder.assertSingle(LogoutEvents.Logout(true))
|
||||
}
|
||||
|
|
@ -112,17 +95,12 @@ class LogoutViewTest {
|
|||
@Test
|
||||
fun `clicking on cancel after error sends a LogoutEvents`() {
|
||||
val eventsRecorder = EventsRecorder<LogoutEvents>()
|
||||
rule.setContent {
|
||||
LogoutView(
|
||||
aLogoutState(
|
||||
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
|
||||
onBackClicked = EnsureNeverCalled(),
|
||||
onSuccessLogout = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
rule.setLogoutView(
|
||||
aLogoutState(
|
||||
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(LogoutEvents.CloseDialogs)
|
||||
}
|
||||
|
|
@ -132,17 +110,13 @@ class LogoutViewTest {
|
|||
val data = "data"
|
||||
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
|
||||
ensureCalledOnceWithParam<String?>(data) { callback ->
|
||||
rule.setContent {
|
||||
LogoutView(
|
||||
aLogoutState(
|
||||
logoutAction = AsyncAction.Success(data),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
|
||||
onBackClicked = EnsureNeverCalled(),
|
||||
onSuccessLogout = callback,
|
||||
)
|
||||
}
|
||||
rule.setLogoutView(
|
||||
aLogoutState(
|
||||
logoutAction = AsyncAction.Success(data),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onSuccessLogout = callback,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,18 +124,30 @@ class LogoutViewTest {
|
|||
fun `last session setting button invoke onChangeRecoveryKeyClicked`() {
|
||||
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setContent {
|
||||
LogoutView(
|
||||
aLogoutState(
|
||||
isLastDevice = true,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onChangeRecoveryKeyClicked = callback,
|
||||
onBackClicked = EnsureNeverCalled(),
|
||||
onSuccessLogout = EnsureNeverCalledWithParam(),
|
||||
)
|
||||
}
|
||||
rule.setLogoutView(
|
||||
aLogoutState(
|
||||
isLastDevice = true,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onChangeRecoveryKeyClicked = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLogoutView(
|
||||
state: LogoutState,
|
||||
onChangeRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onBackClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onSuccessLogout: (logoutUrlResult: String?) -> Unit = EnsureNeverCalledWithParam()
|
||||
) {
|
||||
setContent {
|
||||
LogoutView(
|
||||
state = state,
|
||||
onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked,
|
||||
onBackClicked = onBackClicked,
|
||||
onSuccessLogout = onSuccessLogout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl.direct
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutEvents
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DefaultDirectLogoutViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on confirm logout sends expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
|
||||
rule.setDefaultDirectLogoutView(
|
||||
state = aDirectLogoutState(
|
||||
logoutAction = AsyncAction.Confirming,
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_signout)
|
||||
eventsRecorder.assertSingle(DirectLogoutEvents.Logout(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on cancel logout sends expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
|
||||
rule.setDefaultDirectLogoutView(
|
||||
state = aDirectLogoutState(
|
||||
logoutAction = AsyncAction.Confirming,
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
|
||||
}
|
||||
|
||||
@Ignore("Pressing back key should dismiss the dialog, and so generate the expected event, but it's not the case.")
|
||||
@Test
|
||||
fun `clicking on back invoke back callback`() {
|
||||
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
|
||||
rule.setDefaultDirectLogoutView(
|
||||
state = aDirectLogoutState(
|
||||
logoutAction = AsyncAction.Confirming,
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on confirm after error sends expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
|
||||
rule.setDefaultDirectLogoutView(
|
||||
state = aDirectLogoutState(
|
||||
logoutAction = AsyncAction.Failure(Exception("Error")),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_signout_anyway)
|
||||
eventsRecorder.assertSingle(DirectLogoutEvents.Logout(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on cancel after error sends expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
|
||||
rule.setDefaultDirectLogoutView(
|
||||
state = aDirectLogoutState(
|
||||
logoutAction = AsyncAction.Failure(Exception("Error")),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success logout invoke expected callback and sends expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<DirectLogoutEvents>(expectEvents = false)
|
||||
ensureCalledOnceWithParam<String?>(null) { callback ->
|
||||
rule.setDefaultDirectLogoutView(
|
||||
state = aDirectLogoutState(
|
||||
logoutAction = AsyncAction.Success(null),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onSuccessLogout = callback
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success logout invoke expected callback and sends expected Event with data`() {
|
||||
val eventsRecorder = EventsRecorder<DirectLogoutEvents>(expectEvents = false)
|
||||
val data = "data"
|
||||
ensureCalledOnceWithParam<String?>(data) { callback ->
|
||||
rule.setDefaultDirectLogoutView(
|
||||
state = aDirectLogoutState(
|
||||
logoutAction = AsyncAction.Success(data),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onSuccessLogout = callback
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDefaultDirectLogoutView(
|
||||
state: DirectLogoutState,
|
||||
onSuccessLogout: (String?) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setContent {
|
||||
DefaultDirectLogoutView().Render(
|
||||
state,
|
||||
onSuccessLogout = onSuccessLogout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,7 @@
|
|||
|
||||
package io.element.android.features.preferences.impl.root
|
||||
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
|
|
@ -37,9 +36,3 @@ fun aPreferencesRootState() = PreferencesRootState(
|
|||
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
|
||||
directLogoutState = aDirectLogoutState(),
|
||||
)
|
||||
|
||||
fun aDirectLogoutState() = DirectLogoutState(
|
||||
canDoDirectSignOut = true,
|
||||
logoutAction = AsyncAction.Uninitialized,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ dependencies {
|
|||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.testtags)
|
||||
api(projects.features.roomdetails.api)
|
||||
api(projects.libraries.usersearch.api)
|
||||
api(projects.services.apperror.api)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@
|
|||
package io.element.android.features.roomdetails.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
|
||||
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
|
@ -29,18 +31,18 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
|||
override val values: Sequence<RoomDetailsState>
|
||||
get() = sequenceOf(
|
||||
aRoomDetailsState(),
|
||||
aRoomDetailsState().copy(roomTopic = RoomTopicState.Hidden),
|
||||
aRoomDetailsState().copy(roomTopic = RoomTopicState.CanAddTopic),
|
||||
aRoomDetailsState().copy(isEncrypted = false),
|
||||
aRoomDetailsState().copy(roomAlias = null),
|
||||
aDmRoomDetailsState().copy(roomName = "Daniel"),
|
||||
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
|
||||
aRoomDetailsState().copy(canInvite = true),
|
||||
aRoomDetailsState().copy(isFavorite = true),
|
||||
aRoomDetailsState().copy(
|
||||
aRoomDetailsState(roomTopic = RoomTopicState.Hidden),
|
||||
aRoomDetailsState(roomTopic = RoomTopicState.CanAddTopic),
|
||||
aRoomDetailsState(isEncrypted = false),
|
||||
aRoomDetailsState(roomAlias = null),
|
||||
aDmRoomDetailsState(),
|
||||
aDmRoomDetailsState(isDmMemberIgnored = true),
|
||||
aRoomDetailsState(canInvite = true),
|
||||
aRoomDetailsState(isFavorite = true),
|
||||
aRoomDetailsState(
|
||||
canEdit = true,
|
||||
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
|
||||
roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
|
||||
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
|
||||
),
|
||||
// Add other state here
|
||||
)
|
||||
|
|
@ -68,32 +70,61 @@ fun aDmRoomMember(
|
|||
role = role,
|
||||
)
|
||||
|
||||
fun aRoomDetailsState() = RoomDetailsState(
|
||||
roomId = "a room id",
|
||||
roomName = "Marketing",
|
||||
roomAlias = "#marketing:domain.com",
|
||||
roomAvatarUrl = null,
|
||||
roomTopic = RoomTopicState.ExistingTopic(
|
||||
fun aRoomDetailsState(
|
||||
roomId: String = "a room id",
|
||||
roomName: String = "Marketing",
|
||||
roomAlias: String? = "#marketing:domain.com",
|
||||
roomAvatarUrl: String? = null,
|
||||
roomTopic: RoomTopicState = RoomTopicState.ExistingTopic(
|
||||
"Welcome to #marketing, home of the Marketing team " +
|
||||
"|| WIKI PAGE: https://domain.org/wiki/Marketing " +
|
||||
"|| MAIL iki/Marketing " +
|
||||
"|| MAI iki/Marketing " +
|
||||
"|| MAI iki/Marketing..."
|
||||
),
|
||||
memberCount = 32,
|
||||
isEncrypted = true,
|
||||
canInvite = false,
|
||||
canEdit = false,
|
||||
canShowNotificationSettings = true,
|
||||
roomType = RoomDetailsType.Room,
|
||||
roomMemberDetailsState = null,
|
||||
leaveRoomState = aLeaveRoomState(),
|
||||
roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.MUTE, isDefault = false),
|
||||
isFavorite = false,
|
||||
eventSink = {}
|
||||
memberCount: Long = 32,
|
||||
isEncrypted: Boolean = true,
|
||||
canInvite: Boolean = false,
|
||||
canEdit: Boolean = false,
|
||||
canShowNotificationSettings: Boolean = true,
|
||||
roomType: RoomDetailsType = RoomDetailsType.Room,
|
||||
roomMemberDetailsState: RoomMemberDetailsState? = null,
|
||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||
roomNotificationSettings: RoomNotificationSettings = aRoomNotificationSettings(),
|
||||
isFavorite: Boolean = false,
|
||||
eventSink: (RoomDetailsEvent) -> Unit = {},
|
||||
) = RoomDetailsState(
|
||||
roomId = roomId,
|
||||
roomName = roomName,
|
||||
roomAlias = roomAlias,
|
||||
roomAvatarUrl = roomAvatarUrl,
|
||||
roomTopic = roomTopic,
|
||||
memberCount = memberCount,
|
||||
isEncrypted = isEncrypted,
|
||||
canInvite = canInvite,
|
||||
canEdit = canEdit,
|
||||
canShowNotificationSettings = canShowNotificationSettings,
|
||||
roomType = roomType,
|
||||
roomMemberDetailsState = roomMemberDetailsState,
|
||||
leaveRoomState = leaveRoomState,
|
||||
roomNotificationSettings = roomNotificationSettings,
|
||||
isFavorite = isFavorite,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
fun aDmRoomDetailsState(isDmMemberIgnored: Boolean = false) = aRoomDetailsState().copy(
|
||||
fun aRoomNotificationSettings(
|
||||
mode: RoomNotificationMode = RoomNotificationMode.MUTE,
|
||||
isDefault: Boolean = false,
|
||||
) = RoomNotificationSettings(
|
||||
mode = mode,
|
||||
isDefault = isDefault
|
||||
)
|
||||
|
||||
fun aDmRoomDetailsState(
|
||||
isDmMemberIgnored: Boolean = false,
|
||||
roomName: String = "Daniel",
|
||||
) = aRoomDetailsState(
|
||||
roomName = roomName,
|
||||
roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = isDmMemberIgnored)),
|
||||
roomMemberDetailsState = aRoomMemberDetailsState()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
|
|||
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.ClickableLinkText
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
|
|
@ -80,6 +81,8 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
|
|||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.getBestName
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
|
|
@ -221,7 +224,7 @@ private fun RoomDetailsTopBar(
|
|||
actions = {
|
||||
if (showEdit) {
|
||||
IconButton(onClick = { showMenu = !showMenu }) {
|
||||
Icon(Icons.Default.MoreVert, "")
|
||||
Icon(Icons.Default.MoreVert, stringResource(id = CommonStrings.a11y_user_menu))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
|
|
@ -299,6 +302,7 @@ private fun RoomHeaderSection(
|
|||
modifier = Modifier
|
||||
.size(70.dp)
|
||||
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
|
||||
.testTag(TestTags.roomDetailAvatar)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
|
|
@ -463,6 +467,7 @@ internal fun RoomDetailsPreview(@PreviewParameter(RoomDetailsStateProvider::clas
|
|||
internal fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@ExcludeFromCoverage
|
||||
@Composable
|
||||
private fun ContentToPreview(state: RoomDetailsState) {
|
||||
RoomDetailsView(
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
|
||||
@Composable
|
||||
fun RoomMemberHeaderSection(
|
||||
|
|
@ -53,6 +55,7 @@ fun RoomMemberHeaderSection(
|
|||
modifier = Modifier
|
||||
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
|
||||
.fillMaxSize()
|
||||
.testTag(TestTags.memberDetailAvatar)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@
|
|||
package io.element.android.features.roomdetails.impl.notificationsettings
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.roomdetails.impl.aRoomNotificationSettings
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
|
||||
|
||||
internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
|
||||
override val values: Sequence<RoomNotificationSettingsState>
|
||||
|
|
@ -43,7 +43,7 @@ internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<
|
|||
return RoomNotificationSettingsState(
|
||||
showUserDefinedSettingStyle = false,
|
||||
roomName = "Room 1",
|
||||
AsyncData.Success(RoomNotificationSettings(
|
||||
AsyncData.Success(aRoomNotificationSettings(
|
||||
mode = RoomNotificationMode.MUTE,
|
||||
isDefault = isDefault
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@
|
|||
package io.element.android.features.roomdetails.impl.notificationsettings
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.roomdetails.impl.aRoomNotificationSettings
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
|
||||
|
||||
internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
|
||||
override val values: Sequence<RoomNotificationSettingsState>
|
||||
|
|
@ -29,7 +29,7 @@ internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParamet
|
|||
showUserDefinedSettingStyle = false,
|
||||
roomName = "Room 1",
|
||||
AsyncData.Success(
|
||||
RoomNotificationSettings(
|
||||
aRoomNotificationSettings(
|
||||
mode = RoomNotificationMode.MUTE,
|
||||
isDefault = false
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomdetails.impl
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomDetailsViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `click on back invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailView(
|
||||
goBack = callback,
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on share invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailView(
|
||||
onShareRoom = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_details_share_room_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on share member invokes expected callback`() {
|
||||
val state = aDmRoomDetailsState()
|
||||
val roomMember = (state.roomType as RoomDetailsType.Dm).roomMember
|
||||
ensureCalledOnceWithParam(roomMember) { callback ->
|
||||
rule.setRoomDetailView(
|
||||
state = aDmRoomDetailsState(),
|
||||
onShareMember = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_share)
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on room members invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailView(
|
||||
openRoomMemberList = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_people)
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on polls invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailView(
|
||||
openPollHistory = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_polls_history_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on notification invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailView(
|
||||
openRoomNotificationSettings = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_details_notification_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on invite people invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailView(
|
||||
state = aRoomDetailsState(
|
||||
eventSink = EventsRecorder(expectEvents = false),
|
||||
canInvite = true,
|
||||
),
|
||||
invitePeople = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_details_invite_people_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on add topic emit expected event`() {
|
||||
ensureCalledOnceWithParam<RoomDetailsAction>(RoomDetailsAction.AddTopic) { callback ->
|
||||
rule.setRoomDetailView(
|
||||
state = aRoomDetailsState(
|
||||
eventSink = EventsRecorder(expectEvents = false),
|
||||
roomTopic = RoomTopicState.CanAddTopic,
|
||||
),
|
||||
onActionClicked = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_details_add_topic_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on menu edit emit expected event`() {
|
||||
ensureCalledOnceWithParam<RoomDetailsAction>(RoomDetailsAction.Edit) { callback ->
|
||||
rule.setRoomDetailView(
|
||||
state = aRoomDetailsState(
|
||||
eventSink = EventsRecorder(expectEvents = false),
|
||||
canEdit = true,
|
||||
),
|
||||
onActionClicked = callback,
|
||||
)
|
||||
val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu)
|
||||
rule.onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
rule.clickOn(CommonStrings.action_edit)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on avatar test`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEvent>(expectEvents = false)
|
||||
val state = aRoomDetailsState(
|
||||
eventSink = eventsRecorder,
|
||||
roomAvatarUrl = "an_avatar_url",
|
||||
)
|
||||
val callback = EnsureCalledOnceWithTwoParams(state.roomName, "an_avatar_url")
|
||||
rule.setRoomDetailView(
|
||||
state = state,
|
||||
openAvatarPreview = callback,
|
||||
)
|
||||
rule.onNodeWithTag(TestTags.roomDetailAvatar.value).performClick()
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on avatar test on DM`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEvent>(expectEvents = false)
|
||||
val state = aRoomDetailsState(
|
||||
roomType = RoomDetailsType.Dm(aDmRoomMember(avatarUrl = "an_avatar_url")),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
val callback = EnsureCalledOnceWithTwoParams("Daniel", "an_avatar_url")
|
||||
rule.setRoomDetailView(
|
||||
state = state,
|
||||
openAvatarPreview = callback,
|
||||
)
|
||||
rule.onNodeWithTag(TestTags.memberDetailAvatar.value).performClick()
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on mute emit expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEvent>()
|
||||
val state = aRoomDetailsState(
|
||||
eventSink = eventsRecorder,
|
||||
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES),
|
||||
)
|
||||
rule.setRoomDetailView(
|
||||
state = state,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_mute)
|
||||
eventsRecorder.assertSingle(RoomDetailsEvent.MuteNotification)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on unmute emit expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEvent>()
|
||||
val state = aRoomDetailsState(
|
||||
eventSink = eventsRecorder,
|
||||
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.MUTE),
|
||||
)
|
||||
rule.setRoomDetailView(
|
||||
state = state,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_unmute)
|
||||
eventsRecorder.assertSingle(RoomDetailsEvent.UnmuteNotification)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on favorite emit expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEvent>()
|
||||
rule.setRoomDetailView(
|
||||
state = aRoomDetailsState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_favourite)
|
||||
eventsRecorder.assertSingle(RoomDetailsEvent.SetFavorite(true))
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on leave emit expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEvent>()
|
||||
rule.setRoomDetailView(
|
||||
state = aRoomDetailsState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_details_leave_room_title)
|
||||
eventsRecorder.assertSingle(RoomDetailsEvent.LeaveRoom)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailView(
|
||||
state: RoomDetailsState = aRoomDetailsState(
|
||||
eventSink = EventsRecorder(expectEvents = false),
|
||||
),
|
||||
goBack: () -> Unit = EnsureNeverCalled(),
|
||||
onActionClicked: (RoomDetailsAction) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onShareRoom: () -> Unit = EnsureNeverCalled(),
|
||||
onShareMember: (RoomMember) -> Unit = EnsureNeverCalledWithParam(),
|
||||
openRoomMemberList: () -> Unit = EnsureNeverCalled(),
|
||||
openRoomNotificationSettings: () -> Unit = EnsureNeverCalled(),
|
||||
invitePeople: () -> Unit = EnsureNeverCalled(),
|
||||
openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
openPollHistory: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
RoomDetailsView(
|
||||
state = state,
|
||||
goBack = goBack,
|
||||
onActionClicked = onActionClicked,
|
||||
onShareRoom = onShareRoom,
|
||||
onShareMember = onShareMember,
|
||||
openRoomMemberList = openRoomMemberList,
|
||||
openRoomNotificationSettings = openRoomNotificationSettings,
|
||||
invitePeople = invitePeople,
|
||||
openAvatarPreview = openAvatarPreview,
|
||||
openPollHistory = openPollHistory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,16 @@ object TestTags {
|
|||
*/
|
||||
val homeScreenSettings = TestTag("home_screen-settings")
|
||||
|
||||
/**
|
||||
* Room detail screen.
|
||||
*/
|
||||
val roomDetailAvatar = TestTag("room_detail-avatar")
|
||||
|
||||
/**
|
||||
* Room member screen.
|
||||
*/
|
||||
val memberDetailAvatar = TestTag("member_detail-avatar")
|
||||
|
||||
/**
|
||||
* Welcome screen.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -55,6 +55,25 @@ class EnsureCalledOnceWithParam<T, R>(
|
|||
}
|
||||
}
|
||||
|
||||
class EnsureCalledOnceWithTwoParams<T, U>(
|
||||
private val expectedParam1: T,
|
||||
private val expectedParam2: U,
|
||||
) : (T, U) -> Unit {
|
||||
private var counter = 0
|
||||
override fun invoke(p1: T, p2: U) {
|
||||
if (p1 != expectedParam1 || p2 != expectedParam2) {
|
||||
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
|
||||
}
|
||||
counter++
|
||||
}
|
||||
|
||||
fun assertSuccess() {
|
||||
if (counter != 1) {
|
||||
throw AssertionError("Expected to be called once, but was called $counter times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut for [<T, R> ensureCalledOnceWithParam] with Unit result.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
|
||||
size 4457
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5951e2e78e261693bdcc71fb1cd205082589fda4b5a73196c6ca3f25c02bf502
|
||||
size 20332
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f5ff11049c6b3fd510015a17fa5e2e016a0cc715f3d4f68850771a20583dbe0
|
||||
size 13185
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7c79bb3727271a550fdaf7a16d3189e707a578781bc3f77af660e9c935444a6e
|
||||
size 18862
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
|
||||
size 4457
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753
|
||||
size 4464
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bea5d8cc4a49aa2ef9ce3b40b716ce448ce8b3afbdb3a8344ee7e8de9e03fbd8
|
||||
size 16895
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8cbceba31af038cb3bed88419a425b2e54b72934969f11c3c1e5901780b93a08
|
||||
size 10768
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ee8cef67513d24b7d8a2d8e0098a63ab3dfc891adc0f1aed43aa05a9a6c5de84
|
||||
size 15528
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753
|
||||
size 4464
|
||||
Loading…
Add table
Add a link
Reference in a new issue