From c7484b4aa06e1cb936a39a0d08d2822c9bc6eebc Mon Sep 17 00:00:00 2001 From: Konstantin Ivanov <54908981+konstantiniiv@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:42:21 +0100 Subject: [PATCH] DROID-2793 Date as an Object | Tech | Observe relations store (#1924) --- .../anytype/di/feature/DateObjectDI.kt | 2 + .../anytype/ui/date/DateObjectFragment.kt | 8 +- .../domain/objects/StoreOfRelations.kt | 6 + .../domain/misc/DefaultRelationsStoreTest.kt | 29 + .../mapping/DateObjectModelsExt.kt | 2 +- .../viewmodel/DateObjectVMFactory.kt | 8 +- .../viewmodel/DateObjectViewModel.kt | 233 ++++--- .../feature_date/viewmodel/SearchParams.kt | 26 + .../feature_date/DateObjectViewModelTest.kt | 649 ++++++++++++++++++ .../feature_date/DefaultCoroutineTestRule.kt | 30 + 10 files changed, 885 insertions(+), 108 deletions(-) create mode 100644 feature-date/src/test/java/com/anytypeio/anytype/feature_date/DateObjectViewModelTest.kt create mode 100644 feature-date/src/test/java/com/anytypeio/anytype/feature_date/DefaultCoroutineTestRule.kt diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/DateObjectDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/DateObjectDI.kt index 72b30bdc44..4cee1e00e1 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/DateObjectDI.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/DateObjectDI.kt @@ -18,6 +18,7 @@ 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.`object`.SetObjectDetails +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 @@ -172,4 +173,5 @@ interface DateObjectDependencies : ComponentDependencies { fun provideSpaceSyncAndP2PStatusProvider(): SpaceSyncAndP2PStatusProvider fun provideUserSettingsRepository(): UserSettingsRepository fun fieldParser(): FieldParser + fun provideGetDateObjectByTimestamp(): GetDateObjectByTimestamp } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/date/DateObjectFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/date/DateObjectFragment.kt index 6235fd341d..727f1d5db0 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/date/DateObjectFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/date/DateObjectFragment.kt @@ -1,5 +1,6 @@ package com.anytypeio.anytype.ui.date +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -26,6 +27,7 @@ import com.anytypeio.anytype.core_ui.views.BaseAlertDialog import com.anytypeio.anytype.core_utils.ext.argString import com.anytypeio.anytype.core_utils.ext.subscribe import com.anytypeio.anytype.core_utils.ext.toast +import com.anytypeio.anytype.core_utils.insets.EDGE_TO_EDGE_MIN_SDK import com.anytypeio.anytype.core_utils.ui.BaseComposeFragment import com.anytypeio.anytype.di.common.componentManager import com.anytypeio.anytype.feature_date.viewmodel.UiErrorState @@ -251,7 +253,11 @@ class DateObjectFragment : BaseComposeFragment(), ObjectTypeSelectionListener { } override fun onApplyWindowRootInsets(view: View) { - // Do nothing. TODO add ime padding. + if (Build.VERSION.SDK_INT >= EDGE_TO_EDGE_MIN_SDK) { + // Do nothing. + } else { + super.onApplyWindowRootInsets(view) + } } companion object DateLayoutNavigation { diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/objects/StoreOfRelations.kt b/domain/src/main/java/com/anytypeio/anytype/domain/objects/StoreOfRelations.kt index f54f2315d3..d596697ace 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/objects/StoreOfRelations.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/objects/StoreOfRelations.kt @@ -8,12 +8,14 @@ import com.anytypeio.anytype.domain.`object`.amend import com.anytypeio.anytype.domain.`object`.unset import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock interface StoreOfRelations { val size: Int + suspend fun observe(): Flow> suspend fun getByKey(key: Key): ObjectWrapper.Relation? suspend fun getByKeys(keys: List): List suspend fun getById(id: Id): ObjectWrapper.Relation? @@ -126,4 +128,8 @@ class DefaultStoreOfRelations : StoreOfRelations { override fun trackChanges(): Flow = updates.onStart { emit(StoreOfRelations.TrackedEvent.Init) } + + override suspend fun observe(): Flow> { + return trackChanges().map { store } + } } \ No newline at end of file diff --git a/domain/src/test/java/com/anytypeio/anytype/domain/misc/DefaultRelationsStoreTest.kt b/domain/src/test/java/com/anytypeio/anytype/domain/misc/DefaultRelationsStoreTest.kt index 5a20635cbb..5b5ecd910e 100644 --- a/domain/src/test/java/com/anytypeio/anytype/domain/misc/DefaultRelationsStoreTest.kt +++ b/domain/src/test/java/com/anytypeio/anytype/domain/misc/DefaultRelationsStoreTest.kt @@ -1,11 +1,16 @@ package com.anytypeio.anytype.domain.misc +import app.cash.turbine.test +import app.cash.turbine.turbineScope import com.anytypeio.anytype.core_models.ObjectWrapper import com.anytypeio.anytype.core_models.StubRelationObject import com.anytypeio.anytype.domain.objects.DefaultStoreOfRelations import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle class DefaultRelationsStoreTest { @@ -202,4 +207,28 @@ class DefaultRelationsStoreTest { ) } } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should observe changes in store`() = runTest { + turbineScope{ + val store = DefaultStoreOfRelations() + val relation = StubRelationObject() + val relation2 = StubRelationObject() + store.observe().test { + val firstItem = awaitItem() + assertTrue(firstItem.isEmpty(), "Initial store should be empty") + + store.merge(listOf(relation)) + advanceUntilIdle() + val secondItem = awaitItem() + assertEquals(1, secondItem.size, "Store should have one relation after merge") + + store.merge(listOf(relation2)) + advanceUntilIdle() + val thirdItem = awaitItem() + assertEquals(2, thirdItem.size, "Store should have two relations after second merge") + } + } + } } \ No newline at end of file diff --git a/feature-date/src/main/java/com/anytypeio/anytype/feature_date/mapping/DateObjectModelsExt.kt b/feature-date/src/main/java/com/anytypeio/anytype/feature_date/mapping/DateObjectModelsExt.kt index 636be5787c..ed7470c7e8 100644 --- a/feature-date/src/main/java/com/anytypeio/anytype/feature_date/mapping/DateObjectModelsExt.kt +++ b/feature-date/src/main/java/com/anytypeio/anytype/feature_date/mapping/DateObjectModelsExt.kt @@ -24,7 +24,7 @@ suspend fun List.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) { diff --git a/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/DateObjectVMFactory.kt b/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/DateObjectVMFactory.kt index 4d515fa780..af4fa4c380 100644 --- a/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/DateObjectVMFactory.kt +++ b/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/DateObjectVMFactory.kt @@ -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 } \ No newline at end of file diff --git a/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/DateObjectViewModel.kt b/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/DateObjectViewModel.kt index e45bca4566..8a837b2227 100644 --- a/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/DateObjectViewModel.kt +++ b/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/DateObjectViewModel.kt @@ -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.Hidden) @@ -114,6 +117,7 @@ class DateObjectViewModel( private val _dateId = MutableStateFlow(null) private val _dateTimestamp = MutableStateFlow(null) private val _activeField = MutableStateFlow(null) + private val _dateObjectFieldIds: MutableStateFlow> = 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( - 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( + 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) { - val relation = relations.getOrNull(0) - if (relation == null) { - Timber.e("Error getting relation") + private fun initFieldsState(items: List) { + 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) { diff --git a/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/SearchParams.kt b/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/SearchParams.kt index 72e3a6bcab..50d8544154 100644 --- a/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/SearchParams.kt +++ b/feature-date/src/main/java/com/anytypeio/anytype/feature_date/viewmodel/SearchParams.kt @@ -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, diff --git a/feature-date/src/test/java/com/anytypeio/anytype/feature_date/DateObjectViewModelTest.kt b/feature-date/src/test/java/com/anytypeio/anytype/feature_date/DateObjectViewModelTest.kt new file mode 100644 index 0000000000..83fdde34b4 --- /dev/null +++ b/feature-date/src/test/java/com/anytypeio/anytype/feature_date/DateObjectViewModelTest.kt @@ -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) { + 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>()) + + // 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>()) + + // 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>()) + + // 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>()) + + // 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>()) + + // 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 + ) + } +} \ No newline at end of file diff --git a/feature-date/src/test/java/com/anytypeio/anytype/feature_date/DefaultCoroutineTestRule.kt b/feature-date/src/test/java/com/anytypeio/anytype/feature_date/DefaultCoroutineTestRule.kt new file mode 100644 index 0000000000..42f81ff45c --- /dev/null +++ b/feature-date/src/test/java/com/anytypeio/anytype/feature_date/DefaultCoroutineTestRule.kt @@ -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() + +} \ No newline at end of file