mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 05:47:05 +09:00
DROID-2793 Date as an Object | Tech | Observe relations store (#1924)
This commit is contained in:
parent
998cd1cf2d
commit
9ed299a3c2
10 changed files with 885 additions and 108 deletions
|
@ -24,7 +24,7 @@ suspend fun List<RelationListWithValueItem>.toUiFieldsItem(
|
|||
.mapNotNull { item ->
|
||||
val relation = storeOfRelations.getByKey(item.key.key)
|
||||
if (relation == null) {
|
||||
Timber.e("Relation ${item.key.key} not found in the relation store")
|
||||
Timber.w("Relation ${item.key.key} not found in the relation store")
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (relation.key == Relations.LINKS || relation.key == Relations.BACKLINKS) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.anytypeio.anytype.domain.misc.DateProvider
|
|||
import com.anytypeio.anytype.domain.misc.UrlBuilder
|
||||
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
|
||||
import com.anytypeio.anytype.domain.`object`.GetObject
|
||||
import com.anytypeio.anytype.domain.objects.GetDateObjectByTimestamp
|
||||
import com.anytypeio.anytype.domain.objects.SetObjectListIsArchived
|
||||
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
|
||||
import com.anytypeio.anytype.domain.objects.StoreOfRelations
|
||||
|
@ -33,7 +34,8 @@ class DateObjectVMFactory @Inject constructor(
|
|||
private val spaceSyncAndP2PStatusProvider: SpaceSyncAndP2PStatusProvider,
|
||||
private val createObject: CreateObject,
|
||||
private val fieldParser: FieldParser,
|
||||
private val setObjectListIsArchived: SetObjectListIsArchived
|
||||
private val setObjectListIsArchived: SetObjectListIsArchived,
|
||||
private val getDateObjectByTimestamp: GetDateObjectByTimestamp
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
@ -53,7 +55,7 @@ class DateObjectVMFactory @Inject constructor(
|
|||
spaceSyncAndP2PStatusProvider = spaceSyncAndP2PStatusProvider,
|
||||
createObject = createObject,
|
||||
fieldParser = fieldParser,
|
||||
setObjectListIsArchived = setObjectListIsArchived
|
||||
|
||||
setObjectListIsArchived = setObjectListIsArchived,
|
||||
getDateObjectByTimestamp = getDateObjectByTimestamp
|
||||
) as T
|
||||
}
|
|
@ -8,6 +8,7 @@ import com.anytypeio.anytype.core_models.DATE_PICKER_YEAR_RANGE
|
|||
import com.anytypeio.anytype.core_models.DVSortType
|
||||
import com.anytypeio.anytype.core_models.Id
|
||||
import com.anytypeio.anytype.core_models.ObjectWrapper
|
||||
import com.anytypeio.anytype.core_models.RelationListWithValueItem
|
||||
import com.anytypeio.anytype.core_models.Relations
|
||||
import com.anytypeio.anytype.core_models.TimeInSeconds
|
||||
import com.anytypeio.anytype.core_models.getSingleValue
|
||||
|
@ -21,6 +22,7 @@ import com.anytypeio.anytype.domain.misc.DateProvider
|
|||
import com.anytypeio.anytype.domain.misc.UrlBuilder
|
||||
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
|
||||
import com.anytypeio.anytype.domain.`object`.GetObject
|
||||
import com.anytypeio.anytype.domain.objects.GetDateObjectByTimestamp
|
||||
import com.anytypeio.anytype.domain.objects.SetObjectListIsArchived
|
||||
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
|
||||
import com.anytypeio.anytype.domain.objects.StoreOfRelations
|
||||
|
@ -44,7 +46,6 @@ import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
|
|||
import com.anytypeio.anytype.presentation.home.navigation
|
||||
import com.anytypeio.anytype.presentation.objects.getCreateObjectParams
|
||||
import com.anytypeio.anytype.presentation.search.GlobalSearchViewModel.Companion.DEFAULT_DEBOUNCE_DURATION
|
||||
import com.anytypeio.anytype.presentation.search.ObjectSearchConstants.defaultKeys
|
||||
import com.anytypeio.anytype.presentation.sync.SyncStatusWidgetState
|
||||
import com.anytypeio.anytype.presentation.sync.toSyncStatusWidgetState
|
||||
import kotlin.collections.map
|
||||
|
@ -61,6 +62,7 @@ import kotlinx.coroutines.flow.debounce
|
|||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
@ -91,7 +93,8 @@ class DateObjectViewModel(
|
|||
private val spaceSyncAndP2PStatusProvider: SpaceSyncAndP2PStatusProvider,
|
||||
private val createObject: CreateObject,
|
||||
private val fieldParser: FieldParser,
|
||||
private val setObjectListIsArchived: SetObjectListIsArchived
|
||||
private val setObjectListIsArchived: SetObjectListIsArchived,
|
||||
private val getDateObjectByTimestamp: GetDateObjectByTimestamp
|
||||
) : ViewModel(), AnalyticSpaceHelperDelegate by analyticSpaceHelperDelegate {
|
||||
|
||||
val uiCalendarIconState = MutableStateFlow<UiCalendarIconState>(UiCalendarIconState.Hidden)
|
||||
|
@ -114,6 +117,7 @@ class DateObjectViewModel(
|
|||
private val _dateId = MutableStateFlow<Id?>(null)
|
||||
private val _dateTimestamp = MutableStateFlow<TimeInSeconds?>(null)
|
||||
private val _activeField = MutableStateFlow<ActiveField?>(null)
|
||||
private val _dateObjectFieldIds: MutableStateFlow<List<RelationListWithValueItem>> = MutableStateFlow(emptyList())
|
||||
|
||||
/**
|
||||
* Paging and subscription limit. If true, we can paginate after reaching bottom items.
|
||||
|
@ -144,9 +148,9 @@ class DateObjectViewModel(
|
|||
uiHeaderState.value = UiHeaderState.Loading
|
||||
uiFieldsState.value = UiFieldsState.LoadingState
|
||||
uiObjectsListState.value = UiObjectsListState.LoadingState
|
||||
proceedWithObservingRelationListWithValue()
|
||||
proceedWithObservingPermissions()
|
||||
proceedWithGettingDateObject()
|
||||
proceedWithGettingDateObjectRelationList()
|
||||
proceedWithObservingDateId()
|
||||
proceedWithObservingSyncStatus()
|
||||
setupSearchStateFlow()
|
||||
_dateId.value = vmParams.objectId
|
||||
|
@ -185,14 +189,27 @@ class DateObjectViewModel(
|
|||
|
||||
private fun proceedWithReopenDateObjectByTimestamp(timeInSeconds: TimeInSeconds) {
|
||||
viewModelScope.launch {
|
||||
fieldParser.getDateObjectByTimeInSeconds(
|
||||
timeInSeconds = timeInSeconds,
|
||||
spaceId = vmParams.spaceId,
|
||||
actionSuccess = { obj ->
|
||||
reopenDateObject(obj.id)
|
||||
val params = GetDateObjectByTimestamp.Params(
|
||||
space = vmParams.spaceId,
|
||||
timestampInSeconds = timeInSeconds
|
||||
)
|
||||
getDateObjectByTimestamp.async(params).fold(
|
||||
onSuccess = { dateObject ->
|
||||
val obj = ObjectWrapper.Basic(dateObject.orEmpty())
|
||||
if (obj.isValid) {
|
||||
reopenDateObject(obj.id)
|
||||
} else {
|
||||
Timber.w("Date object is invalid")
|
||||
errorState.value = UiErrorState.Show(
|
||||
Reason.Other(msg = "Couldn't open date object, object is invalid")
|
||||
)
|
||||
}
|
||||
},
|
||||
actionFailure = {
|
||||
Timber.e("GettingDateByTimestamp error, object has no id")
|
||||
onFailure = { e ->
|
||||
Timber.e(e, "Failed to get date object by timestamp :$timeInSeconds")
|
||||
errorState.value = UiErrorState.Show(
|
||||
Reason.Other(msg = "Couldn't open date object:\n${e.message?.take(30)}")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -264,79 +281,102 @@ class DateObjectViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
private fun proceedWithGettingDateObjectRelationList() {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun proceedWithObservingRelationListWithValue() {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
_dateObjectFieldIds,
|
||||
storeOfRelations.observe().filter { it.isNotEmpty() }
|
||||
) { relationIds, _ ->
|
||||
relationIds
|
||||
}.collect { relationIds ->
|
||||
Timber.d("RelationListWithValue: $relationIds")
|
||||
initFieldsState(
|
||||
items = relationIds.toUiFieldsItem(storeOfRelations = storeOfRelations)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun proceedWithObservingDateId() {
|
||||
viewModelScope.launch {
|
||||
_dateId
|
||||
.filterNotNull()
|
||||
.collect { id ->
|
||||
val params = GetObjectRelationListById.Params(
|
||||
space = vmParams.spaceId,
|
||||
value = id
|
||||
)
|
||||
Timber.d("Start RelationListWithValue with params: $params")
|
||||
getObjectRelationListById.async(params).fold(
|
||||
onSuccess = { result ->
|
||||
Timber.d("RelationListWithValue Success: $result")
|
||||
val items =
|
||||
result.toUiFieldsItem(storeOfRelations = storeOfRelations)
|
||||
initFieldsState(items)
|
||||
},
|
||||
onFailure = { e ->
|
||||
Timber.e(e, "RelationListWithValue Error")
|
||||
errorState.value = UiErrorState.Show(
|
||||
Reason.ErrorGettingFields(
|
||||
msg = e.message ?: "Error getting fields"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
Timber.d("Getting date object with id: $id")
|
||||
proceedWithGettingDateObject(id)
|
||||
proceedWithGettingDateObjectRelationList(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun proceedWithGettingDateObject() {
|
||||
viewModelScope.launch {
|
||||
_dateId
|
||||
.filterNotNull()
|
||||
.collect { id ->
|
||||
val params = GetObject.Params(
|
||||
target = id,
|
||||
space = vmParams.spaceId,
|
||||
saveAsLastOpened = true
|
||||
private suspend fun proceedWithGettingDateObjectRelationList(id: Id) {
|
||||
val params = GetObjectRelationListById.Params(
|
||||
space = vmParams.spaceId,
|
||||
value = id
|
||||
)
|
||||
getObjectRelationListById.async(params).fold(
|
||||
onSuccess = { result ->
|
||||
_dateObjectFieldIds.value = result
|
||||
},
|
||||
onFailure = { e ->
|
||||
Timber.e(e, "RelationListWithValue Error")
|
||||
errorState.value = UiErrorState.Show(
|
||||
Reason.ErrorGettingFields(
|
||||
msg = e.message ?: "Error getting fields"
|
||||
)
|
||||
Timber.d("Start GetObject with params: $params")
|
||||
getObject.async(params).fold(
|
||||
onSuccess = { obj ->
|
||||
Timber.d("GetObject Success, obj:[$obj]")
|
||||
val timestampInSeconds =
|
||||
obj.details[id]?.getSingleValue<Double>(
|
||||
Relations.TIMESTAMP
|
||||
)?.toLong()
|
||||
if (timestampInSeconds != null) {
|
||||
_dateTimestamp.value = timestampInSeconds
|
||||
val (formattedDate, _) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = timestampInSeconds * 1000,
|
||||
)
|
||||
uiCalendarIconState.value = UiCalendarIconState.Visible(
|
||||
timestampInSeconds = TimestampInSeconds(timestampInSeconds)
|
||||
)
|
||||
uiHeaderState.value = UiHeaderState.Content(
|
||||
title = formattedDate,
|
||||
relativeDate = dateProvider.calculateRelativeDates(
|
||||
dateInSeconds = timestampInSeconds
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { e -> Timber.e(e, "GetObject Error") }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun proceedWithGettingDateObject(id: Id) {
|
||||
val params = GetObject.Params(
|
||||
target = id,
|
||||
space = vmParams.spaceId,
|
||||
saveAsLastOpened = true
|
||||
)
|
||||
getObject.async(params).fold(
|
||||
onSuccess = { obj ->
|
||||
val timestampInSeconds =
|
||||
obj.details[id]?.getSingleValue<Double>(
|
||||
Relations.TIMESTAMP
|
||||
)?.toLong()
|
||||
if (timestampInSeconds != null) {
|
||||
_dateTimestamp.value = timestampInSeconds
|
||||
val (formattedDate, _) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = timestampInSeconds * 1000,
|
||||
)
|
||||
uiCalendarIconState.value = UiCalendarIconState.Visible(
|
||||
timestampInSeconds = TimestampInSeconds(timestampInSeconds)
|
||||
)
|
||||
uiHeaderState.value = UiHeaderState.Content(
|
||||
title = formattedDate,
|
||||
relativeDate = dateProvider.calculateRelativeDates(
|
||||
dateInSeconds = timestampInSeconds
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Timber.e("Error getting timestamp")
|
||||
errorState.value = UiErrorState.Show(
|
||||
Reason.Other(msg = "Error getting timestamp from date object")
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
Timber.e(e, "GetObject Error")
|
||||
errorState.value = UiErrorState.Show(
|
||||
Reason.Other(
|
||||
msg = "Error opening date object: ${e.message?.take(30) ?: "Unknown error"}"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Subscription
|
||||
private fun subscriptionId() = "date_object_subscription_${vmParams.spaceId}"
|
||||
fun subscriptionId() = "date_object_subscription_${vmParams.spaceId}"
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun setupUiStateFlow() {
|
||||
|
@ -419,29 +459,6 @@ class DateObjectViewModel(
|
|||
return items
|
||||
}
|
||||
|
||||
private fun createSearchParams(
|
||||
dateId: Id,
|
||||
timestamp: TimeInSeconds,
|
||||
field: ActiveField,
|
||||
space: SpaceId,
|
||||
itemsLimit: Int
|
||||
): StoreSearchParams {
|
||||
val (filters, sorts) = filtersAndSortsForSearch(
|
||||
spaces = listOf(space.id),
|
||||
field = field,
|
||||
timestamp = timestamp,
|
||||
dateId = dateId
|
||||
)
|
||||
return StoreSearchParams(
|
||||
space = space,
|
||||
filters = filters,
|
||||
sorts = sorts,
|
||||
keys = defaultKeys,
|
||||
limit = itemsLimit,
|
||||
subscription = subscriptionId()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the limit for the number of items fetched and triggers data reload.
|
||||
*/
|
||||
|
@ -900,28 +917,38 @@ class DateObjectViewModel(
|
|||
//endregion
|
||||
|
||||
//region Ui State
|
||||
private fun initFieldsState(relations: List<UiFieldsItem.Item>) {
|
||||
val relation = relations.getOrNull(0)
|
||||
if (relation == null) {
|
||||
Timber.e("Error getting relation")
|
||||
private fun initFieldsState(items: List<UiFieldsItem.Item>) {
|
||||
Timber.d("Init fields state with items: $items")
|
||||
if (items.isEmpty()) {
|
||||
handleEmptyFieldsState()
|
||||
return
|
||||
}
|
||||
|
||||
val firstItem = items.first()
|
||||
_activeField.value = ActiveField(
|
||||
key = relation.key,
|
||||
format = relation.relationFormat
|
||||
key = firstItem.key,
|
||||
format = firstItem.relationFormat
|
||||
)
|
||||
restartSubscription.value++
|
||||
uiFieldsState.value = UiFieldsState(
|
||||
items = buildList {
|
||||
add(UiFieldsItem.Settings())
|
||||
addAll(relations)
|
||||
addAll(items)
|
||||
},
|
||||
selectedRelationKey = _activeField.value?.key
|
||||
)
|
||||
if (relations.isEmpty()) {
|
||||
uiContentState.value = UiContentState.Empty
|
||||
uiObjectsListState.value = UiObjectsListState.Empty
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEmptyFieldsState() {
|
||||
uiFieldsState.value = UiFieldsState.Empty
|
||||
uiContentState.value = UiContentState.Empty
|
||||
uiObjectsListState.value = UiObjectsListState.Empty
|
||||
_activeField.value = null
|
||||
canPaginate.value = false
|
||||
resetLimit()
|
||||
shouldScrollToTopItems = true
|
||||
uiFieldsSheetState.value = UiFieldsSheetState.Hidden
|
||||
Timber.w("Error getting fields for date object:${_dateId.value}, fields are empty")
|
||||
}
|
||||
|
||||
private fun updateHorizontalListState(selectedItem: UiFieldsItem.Item, needToScroll: Boolean = false) {
|
||||
|
|
|
@ -13,6 +13,32 @@ import com.anytypeio.anytype.core_models.Relations.LAST_MODIFIED_DATE
|
|||
import com.anytypeio.anytype.core_models.Relations.RELATION_KEY
|
||||
import com.anytypeio.anytype.core_models.Relations.TYPE
|
||||
import com.anytypeio.anytype.core_models.TimeInSeconds
|
||||
import com.anytypeio.anytype.core_models.primitives.SpaceId
|
||||
import com.anytypeio.anytype.domain.library.StoreSearchParams
|
||||
import com.anytypeio.anytype.presentation.search.ObjectSearchConstants.defaultKeys
|
||||
|
||||
fun DateObjectViewModel.createSearchParams(
|
||||
dateId: Id,
|
||||
timestamp: TimeInSeconds,
|
||||
field: ActiveField,
|
||||
space: SpaceId,
|
||||
itemsLimit: Int
|
||||
): StoreSearchParams {
|
||||
val (filters, sorts) = filtersAndSortsForSearch(
|
||||
spaces = listOf(space.id),
|
||||
field = field,
|
||||
timestamp = timestamp,
|
||||
dateId = dateId
|
||||
)
|
||||
return StoreSearchParams(
|
||||
space = space,
|
||||
filters = filters,
|
||||
sorts = sorts,
|
||||
keys = defaultKeys,
|
||||
limit = itemsLimit,
|
||||
subscription = subscriptionId()
|
||||
)
|
||||
}
|
||||
|
||||
fun filtersAndSortsForSearch(
|
||||
dateId: Id,
|
||||
|
|
|
@ -0,0 +1,649 @@
|
|||
package com.anytypeio.anytype.feature_date
|
||||
|
||||
import com.anytypeio.anytype.analytics.base.Analytics
|
||||
import com.anytypeio.anytype.core_models.*
|
||||
import com.anytypeio.anytype.core_models.multiplayer.SpaceMemberPermissions
|
||||
import com.anytypeio.anytype.core_models.primitives.RelationKey
|
||||
import com.anytypeio.anytype.core_models.primitives.SpaceId
|
||||
import com.anytypeio.anytype.domain.base.Resultat
|
||||
import com.anytypeio.anytype.domain.event.interactor.SpaceSyncAndP2PStatusProvider
|
||||
import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer
|
||||
import com.anytypeio.anytype.domain.misc.DateProvider
|
||||
import com.anytypeio.anytype.domain.misc.UrlBuilder
|
||||
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
|
||||
import com.anytypeio.anytype.domain.`object`.GetObject
|
||||
import com.anytypeio.anytype.domain.objects.DefaultStoreOfRelations
|
||||
import com.anytypeio.anytype.domain.objects.GetDateObjectByTimestamp
|
||||
import com.anytypeio.anytype.domain.objects.SetObjectListIsArchived
|
||||
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
|
||||
import com.anytypeio.anytype.domain.objects.StoreOfRelations
|
||||
import com.anytypeio.anytype.domain.page.CreateObject
|
||||
import com.anytypeio.anytype.domain.primitives.FieldParser
|
||||
import com.anytypeio.anytype.domain.relations.GetObjectRelationListById
|
||||
import com.anytypeio.anytype.feature_date.ui.models.DateEvent
|
||||
import com.anytypeio.anytype.feature_date.viewmodel.ActiveField
|
||||
import com.anytypeio.anytype.feature_date.viewmodel.DateObjectViewModel
|
||||
import com.anytypeio.anytype.feature_date.viewmodel.DateObjectViewModel.Companion.DEFAULT_SEARCH_LIMIT
|
||||
import com.anytypeio.anytype.feature_date.viewmodel.DateObjectVmParams
|
||||
import com.anytypeio.anytype.feature_date.viewmodel.createSearchParams
|
||||
import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.bytebuddy.utility.RandomString
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito.*
|
||||
import org.mockito.MockitoAnnotations
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.verifyBlocking
|
||||
import org.mockito.kotlin.verifyNoInteractions
|
||||
import org.mockito.kotlin.whenever
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class DateObjectViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
var rule: TestRule = DefaultCoroutineTestRule()
|
||||
|
||||
@Mock private lateinit var getObject: GetObject
|
||||
@Mock private lateinit var analytics: Analytics
|
||||
@Mock private lateinit var urlBuilder: UrlBuilder
|
||||
@Mock lateinit var dateProvider: DateProvider
|
||||
@Mock private lateinit var analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate
|
||||
@Mock private lateinit var userPermissionProvider: UserPermissionProvider
|
||||
@Mock private lateinit var relationListWithValue: GetObjectRelationListById
|
||||
@Mock private lateinit var storeOfObjectTypes: StoreOfObjectTypes
|
||||
@Mock private lateinit var storelessSubscriptionContainer: StorelessSubscriptionContainer
|
||||
@Mock private lateinit var spaceSyncAndP2PStatusProvider: SpaceSyncAndP2PStatusProvider
|
||||
@Mock lateinit var createObject: CreateObject
|
||||
@Mock lateinit var setObjectListIsArchived: SetObjectListIsArchived
|
||||
@Mock lateinit var fieldParser: FieldParser
|
||||
@Mock lateinit var getDateObjectByTimestamp: GetDateObjectByTimestamp
|
||||
private lateinit var storeOfRelations: StoreOfRelations
|
||||
|
||||
private val spaceId = SpaceId("testSpaceId")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MockitoAnnotations.openMocks(this)
|
||||
storelessSubscriptionContainer = mock(verboseLogging = true)
|
||||
setupDefaultMocks()
|
||||
storeOfRelations = DefaultStoreOfRelations()
|
||||
}
|
||||
|
||||
private fun setupDefaultMocks() {
|
||||
`when`(userPermissionProvider.get(space = spaceId)).thenReturn(SpaceMemberPermissions.OWNER)
|
||||
`when`(userPermissionProvider.observe(space = spaceId)).thenReturn(flowOf(SpaceMemberPermissions.OWNER))
|
||||
}
|
||||
|
||||
private fun createGetObjectParams(objectId: String): GetObject.Params {
|
||||
return GetObject.Params(
|
||||
target = objectId,
|
||||
space = spaceId,
|
||||
saveAsLastOpened = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRelationListWithValueParams(objectId: String): GetObjectRelationListById.Params {
|
||||
return GetObjectRelationListById.Params(
|
||||
space = spaceId,
|
||||
value = objectId
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun mockGetObjectSuccess(objectId: String, stubObjectView: ObjectView) {
|
||||
val params = createGetObjectParams(objectId)
|
||||
whenever(getObject.async(params)).thenReturn(Resultat.success(stubObjectView))
|
||||
}
|
||||
|
||||
private suspend fun mockRelationListWithValueSuccess(objectId: String, list: List<RelationListWithValueItem>) {
|
||||
val params = createRelationListWithValueParams(objectId)
|
||||
whenever(relationListWithValue.async(params)).thenReturn(Resultat.success(list))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should call getObjects and getRelationListWithValue on init`() = runTest {
|
||||
|
||||
// Arrange
|
||||
val objectId = "testObjectId-${RandomString.make()}"
|
||||
val stubObjectView = StubObjectView(root = objectId)
|
||||
mockGetObjectSuccess(objectId, stubObjectView)
|
||||
mockRelationListWithValueSuccess(objectId, emptyList())
|
||||
|
||||
// Act
|
||||
getViewModel(objectId = objectId, spaceId = spaceId)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyBlocking(getObject, times(1)) { async(createGetObjectParams(objectId)) }
|
||||
verifyBlocking(relationListWithValue, times(1)) { async(createRelationListWithValueParams(objectId)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should call getObjects and getRelationListWithValue again after the dateObjectId was updated `() =
|
||||
runTest {
|
||||
|
||||
// Arrange
|
||||
val objectId = "testObjectId1-${RandomString.make()}"
|
||||
val nextObjectId = "nextObjectId-${RandomString.make()}"
|
||||
val stubObjectView = StubObjectView(root = objectId)
|
||||
|
||||
mockGetObjectSuccess(objectId, stubObjectView)
|
||||
mockRelationListWithValueSuccess(objectId, emptyList())
|
||||
|
||||
val vm = getViewModel(objectId = objectId, spaceId = spaceId)
|
||||
|
||||
// Act
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyBlocking(getObject, times(1)) { async(createGetObjectParams(objectId)) }
|
||||
verifyBlocking(relationListWithValue, times(1)) { async(createRelationListWithValueParams(objectId)) }
|
||||
|
||||
// Arrange for next call
|
||||
val tomorrowTimestamp = 211L
|
||||
whenever(dateProvider.getTimestampForTomorrowAtStartOfDay())
|
||||
.thenReturn(tomorrowTimestamp)
|
||||
|
||||
val params = GetDateObjectByTimestamp.Params(
|
||||
timestampInSeconds = tomorrowTimestamp,
|
||||
space = spaceId
|
||||
)
|
||||
whenever(getDateObjectByTimestamp.async(params)).thenReturn(
|
||||
Resultat.success(
|
||||
mapOf(
|
||||
Relations.ID to nextObjectId
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
mockGetObjectSuccess(nextObjectId, stubObjectView)
|
||||
mockRelationListWithValueSuccess(nextObjectId, emptyList())
|
||||
|
||||
// Act
|
||||
vm.onDateEvent(DateEvent.Calendar.OnTomorrowClick)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyBlocking(getObject, times(1)) { async(createGetObjectParams(nextObjectId)) }
|
||||
verifyBlocking(relationListWithValue, times(1)) { async(createRelationListWithValueParams(nextObjectId)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should properly filter date object relation links`() =
|
||||
runTest {
|
||||
|
||||
// Arrange
|
||||
|
||||
val fieldKey = RelationKey("key1-${RandomString.make()}")
|
||||
val fieldKey2 = RelationKey("key2-${RandomString.make()}")
|
||||
val fieldKey3 = RelationKey("key3-${RandomString.make()}")
|
||||
|
||||
val relationsListWithValues = listOf(
|
||||
RelationListWithValueItem(
|
||||
key = RelationKey(Relations.LINKS),
|
||||
counter = 2L
|
||||
),
|
||||
RelationListWithValueItem(
|
||||
key = fieldKey3,
|
||||
counter = 13L
|
||||
),
|
||||
RelationListWithValueItem(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
counter = 5L
|
||||
),
|
||||
RelationListWithValueItem(
|
||||
key = RelationKey(Relations.BACKLINKS),
|
||||
counter = 5L
|
||||
),
|
||||
RelationListWithValueItem(
|
||||
key = fieldKey, //hidden
|
||||
counter = 9L
|
||||
),
|
||||
RelationListWithValueItem(
|
||||
key = fieldKey2,
|
||||
counter = 1L
|
||||
),
|
||||
RelationListWithValueItem(
|
||||
key = RelationKey(RandomString.make()), // not present in the store
|
||||
counter = 1L
|
||||
),
|
||||
)
|
||||
|
||||
whenever(dateProvider.formatTimestampToDateAndTime(123 * 1000))
|
||||
.thenReturn("01-01-2024" to "12:00")
|
||||
whenever(dateProvider.calculateRelativeDates(123))
|
||||
.thenReturn(
|
||||
RelativeDate.Other(
|
||||
initialTimeInMillis = 123 * 1000,
|
||||
dayOfWeek = DayOfWeekCustom.MONDAY,
|
||||
formattedDate = "01-01-2024",
|
||||
formattedTime = "12:00"
|
||||
)
|
||||
)
|
||||
|
||||
val objectId = "testObjectId1-${RandomString.make()}"
|
||||
val stubObjectView = StubObjectView(
|
||||
root = objectId,
|
||||
details = mapOf(
|
||||
objectId to mapOf(
|
||||
Relations.TIMESTAMP to 123.0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
mockGetObjectSuccess(objectId, stubObjectView)
|
||||
mockRelationListWithValueSuccess(objectId, relationsListWithValues)
|
||||
|
||||
val vm = getViewModel(objectId = objectId, spaceId = spaceId)
|
||||
|
||||
val subscribeParams = vm.createSearchParams(
|
||||
dateId = objectId,
|
||||
timestamp = 123,
|
||||
space = spaceId,
|
||||
itemsLimit = DEFAULT_SEARCH_LIMIT,
|
||||
field = ActiveField(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
format = RelationFormat.OBJECT
|
||||
)
|
||||
)
|
||||
|
||||
whenever(storelessSubscriptionContainer.subscribe(subscribeParams))
|
||||
.thenReturn(emptyFlow<List<ObjectWrapper.Basic>>())
|
||||
|
||||
// Act
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyNoInteractions(storelessSubscriptionContainer)
|
||||
|
||||
// Arrange, list of relations in store
|
||||
val relationObjects = listOf(
|
||||
StubRelationObject(
|
||||
key = fieldKey.key,
|
||||
name = "Hidden field",
|
||||
format = RelationFormat.OBJECT,
|
||||
isHidden = true
|
||||
),
|
||||
StubRelationObject(
|
||||
key = fieldKey2.key,
|
||||
name = "Some date relations 1",
|
||||
format = RelationFormat.DATE
|
||||
),
|
||||
StubRelationObject(
|
||||
key = Relations.LINKS,
|
||||
name = "Links",
|
||||
format = RelationFormat.OBJECT
|
||||
),
|
||||
StubRelationObject(
|
||||
key = Relations.BACKLINKS,
|
||||
name = "Backlinks",
|
||||
format = RelationFormat.OBJECT
|
||||
),
|
||||
StubRelationObject(
|
||||
key = Relations.MENTIONS,
|
||||
name = "Mentions",
|
||||
format = RelationFormat.OBJECT
|
||||
),
|
||||
StubRelationObject(
|
||||
key = fieldKey3.key,
|
||||
name = "Some date relations 2",
|
||||
format = RelationFormat.DATE
|
||||
),
|
||||
)
|
||||
|
||||
// Act, store of relations get updated
|
||||
storeOfRelations.merge(relationObjects)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyNoInteractions(storelessSubscriptionContainer)
|
||||
|
||||
// Act
|
||||
vm.onStart()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyBlocking(storelessSubscriptionContainer, times(1)) {
|
||||
subscribe(subscribeParams)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not start the subscription if the store was not updated`() =
|
||||
runTest {
|
||||
|
||||
// Arrange
|
||||
val objectId = "testObjectId1-${RandomString.make()}"
|
||||
val stubObjectView = StubObjectView(
|
||||
root = objectId,
|
||||
details = mapOf(
|
||||
objectId to mapOf(
|
||||
Relations.TIMESTAMP to 123.0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val relationsListWithValues = listOf(
|
||||
RelationListWithValueItem(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
counter = 5L
|
||||
)
|
||||
)
|
||||
|
||||
mockGetObjectSuccess(objectId, stubObjectView)
|
||||
mockRelationListWithValueSuccess(objectId, relationsListWithValues)
|
||||
|
||||
whenever(dateProvider.formatTimestampToDateAndTime(123 * 1000))
|
||||
.thenReturn("01-01-2024" to "12:00")
|
||||
whenever(dateProvider.calculateRelativeDates(123))
|
||||
.thenReturn(
|
||||
RelativeDate.Other(
|
||||
initialTimeInMillis = 123 * 1000,
|
||||
dayOfWeek = DayOfWeekCustom.MONDAY,
|
||||
formattedDate = "01-01-2024",
|
||||
formattedTime = "12:00"
|
||||
)
|
||||
)
|
||||
|
||||
val vm = getViewModel(objectId = objectId, spaceId = spaceId)
|
||||
|
||||
val subscribeParams = vm.createSearchParams(
|
||||
dateId = objectId,
|
||||
timestamp = 123,
|
||||
space = spaceId,
|
||||
itemsLimit = DEFAULT_SEARCH_LIMIT,
|
||||
field = ActiveField(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
format = RelationFormat.OBJECT
|
||||
)
|
||||
)
|
||||
|
||||
whenever(storelessSubscriptionContainer.subscribe(subscribeParams))
|
||||
.thenReturn(emptyFlow<List<ObjectWrapper.Basic>>())
|
||||
|
||||
// Act waiting for the init to finish
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act 2 waiting for the start to finish
|
||||
vm.onStart()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyNoInteractions(storelessSubscriptionContainer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should start the subscription if the store was updated before init`() =
|
||||
runTest {
|
||||
|
||||
// Arrange
|
||||
val objectId = "testObjectId1-${RandomString.make()}"
|
||||
val stubObjectView = StubObjectView(
|
||||
root = objectId,
|
||||
details = mapOf(
|
||||
objectId to mapOf(
|
||||
Relations.TIMESTAMP to 123.0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val relationsListWithValues = listOf(
|
||||
RelationListWithValueItem(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
counter = 5L
|
||||
)
|
||||
)
|
||||
|
||||
mockGetObjectSuccess(objectId, stubObjectView)
|
||||
mockRelationListWithValueSuccess(objectId, relationsListWithValues)
|
||||
|
||||
whenever(dateProvider.formatTimestampToDateAndTime(123 * 1000))
|
||||
.thenReturn("01-01-2024" to "12:00")
|
||||
whenever(dateProvider.calculateRelativeDates(123))
|
||||
.thenReturn(
|
||||
RelativeDate.Other(
|
||||
initialTimeInMillis = 123 * 1000,
|
||||
dayOfWeek = DayOfWeekCustom.MONDAY,
|
||||
formattedDate = "01-01-2024",
|
||||
formattedTime = "12:00"
|
||||
)
|
||||
)
|
||||
|
||||
// Arrange, list of relations in store
|
||||
val relationObjects = listOf(
|
||||
StubRelationObject(
|
||||
key = Relations.MENTIONS,
|
||||
name = "Mentions",
|
||||
format = RelationFormat.OBJECT
|
||||
)
|
||||
)
|
||||
|
||||
// Act, store of relations get updated
|
||||
storeOfRelations.merge(relationObjects)
|
||||
advanceUntilIdle()
|
||||
|
||||
val vm = getViewModel(objectId = objectId, spaceId = spaceId)
|
||||
|
||||
val subscribeParams = vm.createSearchParams(
|
||||
dateId = objectId,
|
||||
timestamp = 123,
|
||||
space = spaceId,
|
||||
itemsLimit = DEFAULT_SEARCH_LIMIT,
|
||||
field = ActiveField(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
format = RelationFormat.OBJECT
|
||||
)
|
||||
)
|
||||
|
||||
whenever(storelessSubscriptionContainer.subscribe(subscribeParams))
|
||||
.thenReturn(emptyFlow<List<ObjectWrapper.Basic>>())
|
||||
|
||||
// Act waiting for the init to finish
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act 2 waiting for the start to finish
|
||||
vm.onStart()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyBlocking(storelessSubscriptionContainer, times(1)) {
|
||||
subscribe(subscribeParams)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should start the subscription if the store was updated before onStart`() =
|
||||
runTest {
|
||||
|
||||
// Arrange
|
||||
val objectId = "testObjectId1-${RandomString.make()}"
|
||||
val stubObjectView = StubObjectView(
|
||||
root = objectId,
|
||||
details = mapOf(
|
||||
objectId to mapOf(
|
||||
Relations.TIMESTAMP to 123.0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val relationsListWithValues = listOf(
|
||||
RelationListWithValueItem(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
counter = 5L
|
||||
)
|
||||
)
|
||||
|
||||
mockGetObjectSuccess(objectId, stubObjectView)
|
||||
mockRelationListWithValueSuccess(objectId, relationsListWithValues)
|
||||
|
||||
whenever(dateProvider.formatTimestampToDateAndTime(123 * 1000))
|
||||
.thenReturn("01-01-2024" to "12:00")
|
||||
whenever(dateProvider.calculateRelativeDates(123))
|
||||
.thenReturn(
|
||||
RelativeDate.Other(
|
||||
initialTimeInMillis = 123 * 1000,
|
||||
dayOfWeek = DayOfWeekCustom.MONDAY,
|
||||
formattedDate = "01-01-2024",
|
||||
formattedTime = "12:00"
|
||||
)
|
||||
)
|
||||
|
||||
val vm = getViewModel(objectId = objectId, spaceId = spaceId)
|
||||
|
||||
val subscribeParams = vm.createSearchParams(
|
||||
dateId = objectId,
|
||||
timestamp = 123,
|
||||
space = spaceId,
|
||||
itemsLimit = DEFAULT_SEARCH_LIMIT,
|
||||
field = ActiveField(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
format = RelationFormat.OBJECT
|
||||
)
|
||||
)
|
||||
|
||||
whenever(storelessSubscriptionContainer.subscribe(subscribeParams))
|
||||
.thenReturn(emptyFlow<List<ObjectWrapper.Basic>>())
|
||||
|
||||
// Act waiting for the init to finish
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyNoInteractions(storelessSubscriptionContainer)
|
||||
|
||||
// Arrange, list of relations in store
|
||||
val relationObjects = listOf(
|
||||
StubRelationObject(
|
||||
key = Relations.MENTIONS,
|
||||
name = "Mentions",
|
||||
format = RelationFormat.OBJECT
|
||||
)
|
||||
)
|
||||
|
||||
// Act, store of relations get updated
|
||||
storeOfRelations.merge(relationObjects)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act 2 waiting for the start to finish
|
||||
vm.onStart()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyBlocking(storelessSubscriptionContainer, times(1)) {
|
||||
subscribe(subscribeParams)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should start the subscription if the store was updated after onStart`() =
|
||||
runTest {
|
||||
|
||||
// Arrange
|
||||
val objectId = "testObjectId1-${RandomString.make()}"
|
||||
val stubObjectView = StubObjectView(
|
||||
root = objectId,
|
||||
details = mapOf(
|
||||
objectId to mapOf(
|
||||
Relations.TIMESTAMP to 123.0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val relationsListWithValues = listOf(
|
||||
RelationListWithValueItem(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
counter = 5L
|
||||
)
|
||||
)
|
||||
|
||||
mockGetObjectSuccess(objectId, stubObjectView)
|
||||
mockRelationListWithValueSuccess(objectId, relationsListWithValues)
|
||||
|
||||
whenever(dateProvider.formatTimestampToDateAndTime(123 * 1000))
|
||||
.thenReturn("01-01-2024" to "12:00")
|
||||
whenever(dateProvider.calculateRelativeDates(123))
|
||||
.thenReturn(
|
||||
RelativeDate.Other(
|
||||
initialTimeInMillis = 123 * 1000,
|
||||
dayOfWeek = DayOfWeekCustom.MONDAY,
|
||||
formattedDate = "01-01-2024",
|
||||
formattedTime = "12:00"
|
||||
)
|
||||
)
|
||||
|
||||
val vm = getViewModel(objectId = objectId, spaceId = spaceId)
|
||||
|
||||
val subscribeParams = vm.createSearchParams(
|
||||
dateId = objectId,
|
||||
timestamp = 123,
|
||||
space = spaceId,
|
||||
itemsLimit = DEFAULT_SEARCH_LIMIT,
|
||||
field = ActiveField(
|
||||
key = RelationKey(Relations.MENTIONS),
|
||||
format = RelationFormat.OBJECT
|
||||
)
|
||||
)
|
||||
|
||||
whenever(storelessSubscriptionContainer.subscribe(subscribeParams))
|
||||
.thenReturn(emptyFlow<List<ObjectWrapper.Basic>>())
|
||||
|
||||
// Act waiting for the init to finish
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act 2 waiting for the start to finish
|
||||
vm.onStart()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyNoInteractions(storelessSubscriptionContainer)
|
||||
|
||||
// Arrange, list of relations in store
|
||||
val relationObjects = listOf(
|
||||
StubRelationObject(
|
||||
key = Relations.MENTIONS,
|
||||
name = "Mentions",
|
||||
format = RelationFormat.OBJECT
|
||||
)
|
||||
)
|
||||
|
||||
// Act, store of relations get updated
|
||||
storeOfRelations.merge(relationObjects)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verifyBlocking(storelessSubscriptionContainer, times(1)) {
|
||||
subscribe(subscribeParams)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getViewModel(objectId: Id, spaceId: SpaceId): DateObjectViewModel {
|
||||
val vmParams = DateObjectVmParams(
|
||||
objectId = objectId,
|
||||
spaceId = spaceId
|
||||
)
|
||||
return DateObjectViewModel(
|
||||
getObject = getObject,
|
||||
analytics = analytics,
|
||||
urlBuilder = urlBuilder,
|
||||
analyticSpaceHelperDelegate = analyticSpaceHelperDelegate,
|
||||
userPermissionProvider = userPermissionProvider,
|
||||
getObjectRelationListById = relationListWithValue,
|
||||
storeOfRelations = storeOfRelations,
|
||||
storeOfObjectTypes = storeOfObjectTypes,
|
||||
storelessSubscriptionContainer = storelessSubscriptionContainer,
|
||||
spaceSyncAndP2PStatusProvider = spaceSyncAndP2PStatusProvider,
|
||||
fieldParser = fieldParser,
|
||||
vmParams = vmParams,
|
||||
dateProvider = dateProvider,
|
||||
createObject = createObject,
|
||||
setObjectListIsArchived = setObjectListIsArchived,
|
||||
getDateObjectByTimestamp = getDateObjectByTimestamp
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package com.anytypeio.anytype.feature_date
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class DefaultCoroutineTestRule(
|
||||
val dispatcher: TestDispatcher = StandardTestDispatcher()
|
||||
) : TestWatcher() {
|
||||
override fun starting(description: Description) {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
override fun finished(description: Description) {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
fun advanceTime(millis: Long = 100L) {
|
||||
dispatcher.scheduler.advanceTimeBy(millis)
|
||||
}
|
||||
|
||||
fun advanceUntilIdle() = dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue