Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package com.interlinedlist.android.feature.lists.ui.views

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.feature.lists.domain.ListView
import com.interlinedlist.android.feature.lists.domain.ListViewConfig
import com.interlinedlist.android.feature.lists.domain.ListViewScope
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Verifies the saved-view switcher surfaces shared and personal views, marks the
* default, and offers only the actions the user is allowed: somebody else's
* shared view can be forked into a personal copy but not renamed or deleted.
*/
@RunWith(AndroidJUnit4::class)
class ListViewSwitcherTest {

@get:Rule
val composeRule = createComposeRule()

private val theirShared = ListView(
id = "v1",
listId = "L1",
userId = "someone-else",
name = "Roadmap",
scope = ListViewScope.SHARED,
config = ListViewConfig.DEFAULT,
isDefault = true,
position = 0,
)

private val myPersonal = theirShared.copy(
id = "v2",
userId = "me",
name = "My cut",
scope = ListViewScope.PERSONAL,
isDefault = false,
)

private fun state(vararg views: ListView) = ListViewsUiState(
views = views.toList(),
selectedViewId = views.firstOrNull()?.id,
currentUserId = "me",
isLoading = false,
)

private fun setContent(
state: ListViewsUiState,
onFork: (ListView) -> Unit = {},
onRename: (ListView) -> Unit = {},
onDelete: (ListView) -> Unit = {},
onSetDefault: (ListView) -> Unit = {},
onSelect: (ListView) -> Unit = {},
onCreate: (String, ListViewScope) -> Unit = { _, _ -> },
) {
composeRule.setContent {
InterlinedListTheme {
ListViewSwitcherContent(
state = state,
onSelect = onSelect,
onSetDefault = onSetDefault,
onFork = onFork,
onRename = onRename,
onDelete = onDelete,
onCreate = onCreate,
)
}
}
}

@Test
fun barShowsTheSelectedViewAndItsDefaultBadge() {
composeRule.setContent {
InterlinedListTheme {
ListViewSwitcherBar(state = state(theirShared, myPersonal), onOpen = {})
}
}

composeRule.onNodeWithTag(ListViewSwitcherTestTags.BAR).assertIsDisplayed()
composeRule.onNodeWithText("Roadmap").assertIsDisplayed()
composeRule.onNodeWithText("Default").assertIsDisplayed()
}

@Test
fun listsSharedAndPersonalViewsSeparately() {
setContent(state(theirShared, myPersonal))

composeRule.onNodeWithText("Shared").assertIsDisplayed()
composeRule.onNodeWithText("Personal").assertIsDisplayed()
composeRule.onNodeWithTag(ListViewSwitcherTestTags.view("v1")).assertIsDisplayed()
composeRule.onNodeWithTag(ListViewSwitcherTestTags.view("v2")).assertIsDisplayed()
}

@Test
fun someoneElsesSharedViewOffersAForkButNotRenameOrDelete() {
var forked: ListView? = null
setContent(state(theirShared), onFork = { forked = it })

composeRule.onNodeWithTag(ListViewSwitcherTestTags.overflow("v1")).performClick()

// Fork is the escape hatch, and says what it does.
composeRule.onNodeWithTag(ListViewSwitcherTestTags.fork("v1")).assertIsDisplayed()
composeRule.onNodeWithText("Copies this view to your own. The shared one is untouched.")
.assertIsDisplayed()
composeRule.onNodeWithTag(ListViewSwitcherTestTags.rename("v1")).assertDoesNotExist()
composeRule.onNodeWithTag(ListViewSwitcherTestTags.delete("v1")).assertDoesNotExist()

composeRule.onNodeWithTag(ListViewSwitcherTestTags.fork("v1")).performClick()
assertThat(forked?.id).isEqualTo("v1")
}

@Test
fun ownViewCanBeRenamedDeletedAndMadeDefault() {
var renamed: ListView? = null
var deleted: ListView? = null
var defaulted: ListView? = null
setContent(
state(myPersonal),
onRename = { renamed = it },
onDelete = { deleted = it },
onSetDefault = { defaulted = it },
)

composeRule.onNodeWithTag(ListViewSwitcherTestTags.overflow("v2")).performClick()
composeRule.onNodeWithTag(ListViewSwitcherTestTags.setDefault("v2")).performClick()
assertThat(defaulted?.id).isEqualTo("v2")

composeRule.onNodeWithTag(ListViewSwitcherTestTags.overflow("v2")).performClick()
composeRule.onNodeWithTag(ListViewSwitcherTestTags.rename("v2")).performClick()
assertThat(renamed?.id).isEqualTo("v2")

composeRule.onNodeWithTag(ListViewSwitcherTestTags.overflow("v2")).performClick()
composeRule.onNodeWithTag(ListViewSwitcherTestTags.delete("v2")).performClick()
assertThat(deleted?.id).isEqualTo("v2")
}

@Test
fun creatingAViewSendsTheNameAndTheChosenScope() {
var created: Pair<String, ListViewScope>? = null
setContent(state(), onCreate = { name, scope -> created = name to scope })

composeRule.onNodeWithTag(ListViewSwitcherTestTags.EMPTY).assertIsDisplayed()
composeRule.onNodeWithTag(ListViewSwitcherTestTags.CREATE_NAME).performTextInput("By status")
composeRule.onNodeWithTag(ListViewSwitcherTestTags.scope(ListViewScope.SHARED)).performClick()
composeRule.onNodeWithTag(ListViewSwitcherTestTags.CREATE_SUBMIT).performClick()

assertThat(created).isEqualTo("By status" to ListViewScope.SHARED)
}

@Test
fun aRefusalFromTheServerIsShownInTheSheet() {
setContent(
state(theirShared).copy(errorMessage = "You cannot modify this view"),
)

composeRule.onNodeWithTag(ListViewSwitcherTestTags.ERROR).assertIsDisplayed()
composeRule.onNodeWithText("You cannot modify this view").assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.interlinedlist.android.feature.lists.data

/**
* Supplies the signed-in user's id so the views UI can tell which saved views
* belong to the current user — only their own may be renamed or deleted, and
* somebody else's shared view is forked instead. Abstracted from `SessionStore`
* (which is Android-backed) so the ViewModel stays unit-testable on the JVM.
*/
fun interface CurrentUserIdProvider {
fun currentUserId(): String?
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.lists.data

import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.core.common.result.map
import com.interlinedlist.android.core.network.error.safeApiCall
import com.interlinedlist.android.feature.lists.data.local.ListDao
Expand All @@ -11,12 +12,15 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.CreateConnection
import com.interlinedlist.android.feature.lists.data.remote.dto.CreateFolderRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.CreateShareLinkRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.CreateViewRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto
import com.interlinedlist.android.feature.lists.data.remote.dto.ListViewEnvelope
import com.interlinedlist.android.feature.lists.data.remote.dto.RowDto
import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateFolderRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateListRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateSchemaRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateViewRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateWatcherRoleRequest
import com.interlinedlist.android.feature.lists.domain.Contributor
import com.interlinedlist.android.feature.lists.domain.ListConnection
Expand All @@ -26,6 +30,9 @@ import com.interlinedlist.android.feature.lists.domain.ListRow
import com.interlinedlist.android.feature.lists.domain.ListSchema
import com.interlinedlist.android.feature.lists.domain.ListSource
import com.interlinedlist.android.feature.lists.domain.ListSummary
import com.interlinedlist.android.feature.lists.domain.ListView
import com.interlinedlist.android.feature.lists.domain.ListViewConfig
import com.interlinedlist.android.feature.lists.domain.ListViewScope
import com.interlinedlist.android.feature.lists.domain.Paged
import com.interlinedlist.android.feature.lists.domain.RefreshResult
import com.interlinedlist.android.feature.lists.domain.ShareLink
Expand Down Expand Up @@ -420,6 +427,79 @@ class DefaultListsRepository @Inject constructor(
safeApiCall(json) { api.deleteConnection(id) }.map { }
}

override suspend fun getViews(listId: String): ApiResult<List<ListView>> =
withContext(dispatchers.io) {
safeApiCall(json) { api.getViews(listId) }
.map { response -> response.items.map { ListViewMapper.fromDto(it, listId) } }
}

override suspend fun createView(
listId: String,
name: String,
scope: ListViewScope?,
config: ListViewConfig?,
isDefault: Boolean,
): ApiResult<ListView> {
val trimmedName = name.trim()
if (trimmedName.isEmpty()) return ApiResult.Failure(AppError.Unknown(MISSING_VIEW_NAME))
// The API 400s on a missing/unknown scope, so don't spend a request on one.
val resolvedScope = scope ?: return ApiResult.Failure(AppError.Unknown(MISSING_VIEW_SCOPE))
return withContext(dispatchers.io) {
val body = CreateViewRequest(
name = trimmedName,
scope = resolvedScope.apiValue,
config = config?.raw,
isDefault = isDefault.takeIf { it },
)
safeApiCall(json) { api.createView(listId, body) }.requireView(listId)
}
}

override suspend fun updateView(
listId: String,
viewId: String,
name: String?,
config: ListViewConfig?,
isDefault: Boolean?,
): ApiResult<ListView> {
val trimmedName = name?.trim()
if (trimmedName != null && trimmedName.isEmpty()) {
return ApiResult.Failure(AppError.Unknown(MISSING_VIEW_NAME))
}
return withContext(dispatchers.io) {
val body = UpdateViewRequest(
name = trimmedName,
config = config?.raw,
isDefault = isDefault,
)
safeApiCall(json) { api.updateView(listId, viewId, body) }.requireView(listId)
}
}

override suspend fun forkView(listId: String, viewId: String): ApiResult<ListView> =
withContext(dispatchers.io) {
safeApiCall(json) { api.forkView(listId, viewId) }.requireView(listId)
}

override suspend fun deleteView(listId: String, viewId: String): ApiResult<Unit> =
withContext(dispatchers.io) {
safeApiCall(json) { api.deleteView(listId, viewId) }.map { }
}

/**
* Unwraps a create/update/fork response into the server's own copy of the
* view. That copy is authoritative: unrecognised `config` values are dropped
* server-side without complaint, so callers must render what came back
* rather than what they sent.
*/
private fun ApiResult<ListViewEnvelope>.requireView(listId: String): ApiResult<ListView> =
when (this) {
is ApiResult.Success -> data.viewOrSelf
?.let { ApiResult.Success(ListViewMapper.fromDto(it, listId)) }
?: ApiResult.Failure(AppError.Unknown(VIEW_NOT_RETURNED))
is ApiResult.Failure -> this
}

override suspend fun getShareLinks(listId: String): ApiResult<List<ShareLink>> =
withContext(dispatchers.io) {
safeApiCall(json) { api.getShareLinks(listId) }
Expand Down Expand Up @@ -474,6 +554,10 @@ class DefaultListsRepository @Inject constructor(
private companion object {
/** Safety net for a breadcrumb walk: deep nesting is not worth the requests. */
const val MAX_PARENT_CHAIN = 10

const val MISSING_VIEW_NAME = "A view needs a name."
const val MISSING_VIEW_SCOPE = "Choose whether the view is shared or personal."
const val VIEW_NOT_RETURNED = "The view was saved but the server did not return it."
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.interlinedlist.android.feature.lists.data

import com.interlinedlist.android.feature.lists.data.remote.dto.ListViewDto
import com.interlinedlist.android.feature.lists.domain.ListView
import com.interlinedlist.android.feature.lists.domain.ListViewConfig
import com.interlinedlist.android.feature.lists.domain.ListViewScope

/** DTO → domain mapping for saved views. */
object ListViewMapper {

/**
* Maps a server view, keeping its `config` verbatim. An unrecognised `scope`
* is treated as [ListViewScope.SHARED]: the conservative reading, since it
* stops the UI offering destructive actions on a view that may not be ours.
*/
fun fromDto(dto: ListViewDto, listId: String): ListView = ListView(
id = dto.id,
listId = dto.listId ?: listId,
userId = dto.userId,
name = dto.name,
scope = ListViewScope.fromApi(dto.scope) ?: ListViewScope.SHARED,
config = ListViewConfig.fromJson(dto.config),
isDefault = dto.isDefault,
position = dto.position,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import com.interlinedlist.android.feature.lists.domain.ListRow
import com.interlinedlist.android.feature.lists.domain.ListSchema
import com.interlinedlist.android.feature.lists.domain.ListSource
import com.interlinedlist.android.feature.lists.domain.ListSummary
import com.interlinedlist.android.feature.lists.domain.ListView
import com.interlinedlist.android.feature.lists.domain.ListViewConfig
import com.interlinedlist.android.feature.lists.domain.ListViewScope
import com.interlinedlist.android.feature.lists.domain.Paged
import com.interlinedlist.android.feature.lists.domain.RefreshResult
import com.interlinedlist.android.feature.lists.domain.ShareLink
Expand Down Expand Up @@ -178,6 +181,54 @@ interface ListsRepository {
/** Removes a connection between lists. */
suspend fun deleteConnection(id: String): ApiResult<Unit>

// --- Saved views -------------------------------------------------------

/**
* Saved views for a list: every shared view plus the caller's own personal
* ones, in the order the server returns them.
*/
suspend fun getViews(listId: String): ApiResult<List<ListView>>

/**
* Creates a saved view. [scope] is nullable because the UI can ask before the
* user has chosen one; a missing or unrecognised scope fails locally without
* spending a request, since the server rejects it with a 400 anyway.
*
* The returned view is the server's own copy: it silently drops [config]
* values it does not recognise, so its echo — not the config sent — is what
* callers must render.
*/
suspend fun createView(
listId: String,
name: String,
scope: ListViewScope?,
config: ListViewConfig? = null,
isDefault: Boolean = false,
): ApiResult<ListView>

/**
* Renames / re-configures a view or makes it the default. Only the supplied
* fields change, and the server's echo is returned for the same reason as
* [createView].
*/
suspend fun updateView(
listId: String,
viewId: String,
name: String? = null,
config: ListViewConfig? = null,
isDefault: Boolean? = null,
): ApiResult<ListView>

/**
* Forks a view into a personal copy named `"<name> (copy)"` — the escape
* hatch when somebody else's shared view does not suit. The original is
* untouched.
*/
suspend fun forkView(listId: String, viewId: String): ApiResult<ListView>

/** Deletes a saved view. The server rejects views the caller does not own. */
suspend fun deleteView(listId: String, viewId: String): ApiResult<Unit>

// --- Sharing -----------------------------------------------------------

/** Existing public share links for a list. */
Expand Down
Loading
Loading