From dc8efae849c72076820d0c78f5313563af3b28a6 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:03:48 -0700 Subject: [PATCH] feat(lists): send the creation options the API accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/lists takes more than title/description/schema/isPublic. CreateListRequest now carries parentId, folderId, messageId, initialRows, metadata and source, each omitted from the body when null so existing callers send exactly the request they always sent, and all of them are plumbed through ListsRepository.createList. initialRows go out in the same flat, column-keyed shape POST /api/lists/{id}/data uses; metadata stays a JsonObject so arbitrary keys round-trip untouched; source is modelled as a ListSource enum ("local"/"github") rather than a raw string. parentId is reachable from the UI: the detail screen offers "New child list" and renders a breadcrumb of the list's ancestors, resolved by walking the parent chain — consuming the nested `parent` object when the server inlines it, fetching the level otherwise, with cycle and depth guards. A level that fails to load truncates the breadcrumb instead of failing the screen. Lists now cache their parentId, so the feature's Room schema moves to v2, and the nav host hands the detail route one onOpenList callback that both breadcrumb hops and newly created child lists use. messageId gets request-layer support plus a createListFromMessage repository call only; the affordance on a message belongs to the cross-object work in :feature:messages. Closes #54 --- .../navigation/InterlinedListNavHost.kt | 2 + .../lists/ui/detail/ListDetailScreenTest.kt | 67 ++++- .../lists/data/DefaultListsRepository.kt | 68 ++++- .../android/feature/lists/data/ListMapper.kt | 3 + .../feature/lists/data/ListsRepository.kt | 52 +++- .../lists/data/local/CachedListEntity.kt | 2 + .../feature/lists/data/local/ListsDatabase.kt | 3 +- .../feature/lists/data/remote/dto/ListDtos.kt | 54 +++- .../feature/lists/domain/ListSource.kt | 17 ++ .../feature/lists/domain/ListSummary.kt | 6 + .../lists/ui/detail/ListDetailScreen.kt | 58 ++++ .../lists/ui/detail/ListDetailViewModel.kt | 81 +++++- .../feature/lists/FakeListsRepository.kt | 46 ++- .../lists/data/CreateListRequestTest.kt | 137 +++++++++ .../DefaultListsRepositoryCreationTest.kt | 266 ++++++++++++++++++ .../ui/detail/ListDetailViewModelTest.kt | 89 ++++++ 16 files changed, 927 insertions(+), 24 deletions(-) create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSource.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/CreateListRequestTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index cf0ec9d..843ee4b 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -351,6 +351,8 @@ private fun MainShell( onEditSchema = { tabNav.navigate(Routes.listSchema(listId)) }, onOpenWatchers = { tabNav.navigate(Routes.listWatchers(listId)) }, onOpenShare = { tabNav.navigate(Routes.listShare(listId)) }, + // Breadcrumb hops and newly created child lists open a detail route. + onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }, ) } composable( diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt index 922ebe9..a51f3bb 100644 --- a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt @@ -34,7 +34,12 @@ class ListDetailScreenTest { ), ) - private fun setScreen(state: ListDetailUiState, onOpenShare: () -> Unit = {}) { + private fun setScreen( + state: ListDetailUiState, + onOpenShare: () -> Unit = {}, + onOpenList: (String) -> Unit = {}, + onNewChildList: () -> Unit = {}, + ) { composeRule.setContent { InterlinedListTheme { ListDetailScreen( @@ -45,6 +50,8 @@ class ListDetailScreenTest { onDeleteRow = {}, onDeleteList = {}, onOpenShare = onOpenShare, + onOpenList = onOpenList, + onNewChildList = onNewChildList, ) } } @@ -101,4 +108,62 @@ class ListDetailScreenTest { composeRule.onNodeWithTag(ListDetailTestTags.SHARE).assertIsDisplayed().performClick() assert(shared) } + + @Test + fun rendersParentBreadcrumb_andOpensAnAncestor() { + var opened: String? = null + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Chapter 3", null, 0, null, false, null, "P1"), + schema = schema, + rows = emptyList(), + isLoading = false, + breadcrumb = listOf( + ListSummary("ROOT", "Book", null, 0, null, false, null, null), + ListSummary("P1", "Part 2", null, 0, null, false, null, "ROOT"), + ), + ), + onOpenList = { opened = it }, + ) + + composeRule.onNodeWithTag(ListDetailTestTags.BREADCRUMB).assertIsDisplayed() + composeRule.onNodeWithText("Book").assertIsDisplayed() + composeRule.onNodeWithText("Part 2").assertIsDisplayed() + + composeRule.onNodeWithTag(ListDetailTestTags.crumb("ROOT")).performClick() + assert(opened == "ROOT") + } + + @Test + fun hidesBreadcrumb_forARootList() { + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 0, null, false, null), + schema = schema, + rows = emptyList(), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(ListDetailTestTags.BREADCRUMB).assertDoesNotExist() + } + + @Test + fun overflowOffersANewChildList() { + var requested = false + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 0, null, false, null), + schema = schema, + rows = emptyList(), + isLoading = false, + ), + onNewChildList = { requested = true }, + ) + + composeRule.onNodeWithTag(ListDetailTestTags.OVERFLOW).performClick() + composeRule.onNodeWithTag(ListDetailTestTags.NEW_CHILD).performClick() + + assert(requested) + } } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt index 6cbb302..e6d5568 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt @@ -24,6 +24,7 @@ import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder 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.Paged import com.interlinedlist.android.feature.lists.domain.RefreshResult @@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import javax.inject.Inject @@ -92,15 +94,33 @@ class DefaultListsRepository @Inject constructor( title: String, description: String?, isPublic: Boolean, + parentId: String?, + folderId: String?, + messageId: String?, + initialRows: List>?, + metadata: JsonObject?, + source: ListSource?, ): ApiResult = withContext(dispatchers.io) { - val body = CreateListRequest(title = title, description = description, isPublic = isPublic) + val body = CreateListRequest( + title = title, + description = description, + isPublic = isPublic, + parentId = parentId, + folderId = folderId, + messageId = messageId, + // Starter rows go out in the same shape a row write uses. + initialRows = initialRows?.map { JsonObject(it.toJsonData()) }, + metadata = metadata, + source = source?.wire, + ) when (val result = safeApiCall(json) { api.createList(body) }) { is ApiResult.Success -> { val dto = result.data.list ?: result.data.data ?: return@withContext ApiResult.Success( ListSummary( id = "", title = title, description = description, - itemCount = 0, folderId = null, isPublic = isPublic, updatedAt = null, + itemCount = 0, folderId = folderId, isPublic = isPublic, + updatedAt = null, parentId = parentId, ), ) val summary = ListMapper.summaryFromDto(dto) @@ -111,6 +131,45 @@ class DefaultListsRepository @Inject constructor( } } + override suspend fun createListFromMessage( + messageId: String, + title: String, + description: String?, + ): ApiResult = + createList(title = title, description = description, messageId = messageId) + + override suspend fun getParentChain(parentId: String): ApiResult> = + withContext(dispatchers.io) { + val ancestors = mutableListOf() // nearest parent first + val visited = mutableSetOf() + // The server may inline the next level under `parent`; when it does, + // that level costs no request. + var inlined: ListDto? = null + var nextId: String? = parentId + + while (ancestors.size < MAX_PARENT_CHAIN) { + // A level already seen means the tree loops: stop before spending + // another request on it. + val dto = inlined?.takeIf { visited.add(it.id) } + ?: nextId?.takeIf { visited.add(it) }?.let { id -> + when (val result = safeApiCall(json) { api.getList(id) }) { + is ApiResult.Success -> result.data.list ?: result.data.data + // A level we cannot fetch truncates the breadcrumb; only a + // chain that resolved nothing at all is reported as a failure. + is ApiResult.Failure -> + if (ancestors.isEmpty()) return@withContext result else null + } + } ?: break + + ancestors += ListMapper.summaryFromDto(dto) + inlined = dto.parent + nextId = dto.parentId + } + + listDao.upsertAll(ancestors.map(ListMapper::summaryToEntity)) + ApiResult.Success(ancestors.reversed()) // root → immediate parent + } + override suspend fun updateList( id: String, title: String?, @@ -411,6 +470,11 @@ class DefaultListsRepository @Inject constructor( private fun Map.toJsonData(): Map = filterValues { it.isNotBlank() } .mapValues { (_, value) -> JsonPrimitive(value) as JsonElement } + + private companion object { + /** Safety net for a breadcrumb walk: deep nesting is not worth the requests. */ + const val MAX_PARENT_CHAIN = 10 + } } /** Builds a [Paged] from the response's pagination block, tolerating its absence. */ diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt index 6ce6bc2..5c71757 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt @@ -21,6 +21,7 @@ object ListMapper { folderId = dto.folderId, isPublic = dto.isPublic, updatedAt = dto.updatedAt, + parentId = dto.parentId ?: dto.parent?.id, ) fun summaryToEntity(summary: ListSummary): CachedListEntity = CachedListEntity( @@ -31,6 +32,7 @@ object ListMapper { folderId = summary.folderId, isPublic = summary.isPublic, updatedAt = summary.updatedAt, + parentId = summary.parentId, ) fun summaryFromEntity(entity: CachedListEntity): ListSummary = ListSummary( @@ -41,6 +43,7 @@ object ListMapper { folderId = entity.folderId, isPublic = entity.isPublic, updatedAt = entity.updatedAt, + parentId = entity.parentId, ) fun folderFromDto(dto: FolderDto): ListFolder = ListFolder( diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt index 3ce1e28..a3a2579 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt @@ -7,6 +7,7 @@ import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder 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.Paged import com.interlinedlist.android.feature.lists.domain.RefreshResult @@ -18,6 +19,7 @@ import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.json.JsonObject /** * Offline-first access to the Lists domain. The index streams from Room (the @@ -38,8 +40,54 @@ interface ListsRepository { /** Server-side search (not cached) by title/description. */ suspend fun searchLists(query: String, limit: Int = DEFAULT_PAGE_SIZE): ApiResult> - /** Creates a list; caches the result and returns its summary. */ - suspend fun createList(title: String, description: String?, isPublic: Boolean): ApiResult + /** + * Creates a list; caches the result and returns its summary. + * + * Everything past [isPublic] is an optional creation option the API accepts + * and is omitted from the request when null, so callers that only need a + * title/description send exactly what they always sent: + * - [parentId] nests the new list under an existing one (see [getParentChain]). + * - [folderId] files it into a folder. + * - [messageId] records the message the list was created from. + * - [initialRows] seeds starter rows, each a column-key → value map, exactly + * as [addRow] sends a row. + * - [metadata] is a free-form JSON object passed through untouched. + * - [source] marks where the rows come from (defaults to the server's own). + */ + suspend fun createList( + title: String, + description: String? = null, + isPublic: Boolean = false, + parentId: String? = null, + folderId: String? = null, + messageId: String? = null, + initialRows: List>? = null, + metadata: JsonObject? = null, + source: ListSource? = null, + ): ApiResult + + /** + * Creates a list from a message: the message becomes the new list's + * description and the API keeps the link via `messageId`. + * + * The entry point that offers this on a message lives in `:feature:messages` + * (see the cross-object "create from" work); this is the call it makes. + */ + suspend fun createListFromMessage( + messageId: String, + title: String, + description: String?, + ): ApiResult + + /** + * Resolves a list's ancestry for breadcrumb navigation: fetches [parentId] + * and keeps walking up, returning the chain ordered root → immediate parent. + * + * A level the server cannot serve truncates the chain rather than failing, + * since a breadcrumb is navigation decoration; a failure is only reported + * when nothing at all could be resolved. + */ + suspend fun getParentChain(parentId: String): ApiResult> /** * Updates a list's metadata (title/description/visibility/folder). Only the diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt index ccdb761..3621805 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt @@ -16,4 +16,6 @@ data class CachedListEntity( val folderId: String?, val isPublic: Boolean, val updatedAt: String?, + /** Parent list id — kept so a cached list still knows where it sits in the tree. */ + val parentId: String? = null, ) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt index 772b9d3..d6be1cd 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt @@ -10,7 +10,8 @@ import androidx.room.RoomDatabase */ @Database( entities = [CachedListEntity::class], - version = 1, + // v2 adds CachedListEntity.parentId (list tree / breadcrumb). + version = 2, exportSchema = false, ) abstract class ListsDatabase : RoomDatabase() { diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt index c4bd4ea..15a293c 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.lists.data.remote.dto import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject /** * Wire models for the Lists API. Field names follow the InterlinedList REST @@ -16,7 +17,14 @@ import kotlinx.serialization.json.JsonElement * @Serializable shapes. */ -/** A list envelope as returned by index and detail endpoints. */ +/** + * A list envelope as returned by index and detail endpoints. + * + * `GET /api/lists` returns each row with its tree links: `parentId` plus a nested + * `parent` object (null at the root) and a `children` array. The nested [parent] + * is modelled recursively so the breadcrumb can consume whatever depth the server + * chooses to send and only fetch the levels it did not. + */ @Serializable data class ListDto( val id: String, @@ -28,6 +36,18 @@ data class ListDto( val folderId: String? = null, val isPublic: Boolean = false, val updatedAt: String? = null, + /** The list this one hangs under; `null` for a root list. */ + val parentId: String? = null, + /** The parent as an object, when the server inlines it. */ + val parent: ListDto? = null, + /** Direct children, when the server inlines them. */ + val children: List? = null, + /** The message this list was created from, if any. */ + val messageId: String? = null, + /** Free-form pass-through object; kept raw so no keys are lost. */ + val metadata: JsonElement? = null, + /** Where the rows come from — `"local"`, `"github"`, … (see `ListSource`). */ + val source: String? = null, // Detail responses may inline the schema; the mapper handles either shape. val schema: JsonElement? = null, ) @@ -62,13 +82,41 @@ data class ListEnvelope( val data: ListDto? = null, ) -/** Body for `POST /api/lists`. `schema` is a serialised DSL string per the API. */ +/** + * Body for `POST /api/lists`. + * + * Every field beyond [title] is optional and defaults to `null` so it is + * **omitted** from the JSON rather than sent as an explicit `null` — a caller + * that only wants a title still produces `{"title":"…"}`. + * + * - [schema] is the List Schema DSL **object** (`{ name, description?, fields[] }`), + * per the help centre's Lists API reference — not a serialised string (that form + * belongs to `PUT /api/lists/{id}/schema`). + * - [initialRows] are starter rows; each is the same flat, column-keyed object + * that `POST /api/lists/{id}/data` sends under `data`. The OpenAPI spec types + * this field as a bare `string` and the help centre does not document it at all, + * so the element shape follows the list-data API rather than the generated spec. + * - [metadata] is undocumented and free-form, so it is kept as [JsonObject] and + * passed through with every key intact. + * - [source] is the low-cardinality list source string — `"local"` or `"github"` + * (see [com.interlinedlist.android.feature.lists.domain.ListSource]). + * - [folderId] is a real column on a list, but neither the OpenAPI create schema + * nor the help centre lists it as a *create* field: filing a list into a folder + * is documented on `PUT /api/lists/{id}`. It is sent when supplied; a caller that + * must be certain should follow up with an update. + */ @Serializable data class CreateListRequest( val title: String, val description: String? = null, - val schema: String? = null, + val schema: JsonObject? = null, val isPublic: Boolean = false, + val parentId: String? = null, + val folderId: String? = null, + val messageId: String? = null, + val initialRows: List? = null, + val metadata: JsonObject? = null, + val source: String? = null, ) /** Body for `PUT /api/lists/{id}` — partial metadata updates. */ diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSource.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSource.kt new file mode 100644 index 0000000..e115d6e --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSource.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * Where a list's rows come from. The API stores this as a short string on the + * list row (`source`), observed as `"local"` for ordinary lists; `"github"` marks + * a repository-backed list that `POST /api/lists/{id}/refresh` re-syncs. + * + * Modelled as an enum so callers pass a value the API recognises instead of a + * free-form string. + */ +enum class ListSource(val wire: String) { + /** A normal list whose rows are edited in the app. */ + LOCAL("local"), + + /** A list mirrored from a GitHub repository. */ + GITHUB("github"), +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt index 0e40a9c..1e878a3 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt @@ -13,4 +13,10 @@ data class ListSummary( val folderId: String?, val isPublic: Boolean, val updatedAt: String?, + /** + * The list this one hangs under, if any. Lists form a tree, so the detail + * screen walks this up to render a breadcrumb + * ([com.interlinedlist.android.feature.lists.data.ListsRepository.getParentChain]). + */ + val parentId: String? = null, ) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt index 6656ad1..ca52a36 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt @@ -1,5 +1,6 @@ package com.interlinedlist.android.feature.lists.ui.detail +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -73,7 +74,10 @@ object ListDetailTestTags { const val EDIT_SCHEMA = "listDetailEditSchema" const val WATCHERS = "listDetailWatchers" const val SHARE = "listDetailShare" + const val NEW_CHILD = "listDetailNewChild" + const val BREADCRUMB = "listDetailBreadcrumb" fun row(id: String) = "listDetailRow_$id" + fun crumb(id: String) = "listDetailCrumb_$id" } /** @@ -91,6 +95,7 @@ fun ListDetailRoute( onOpenWatchers: () -> Unit, modifier: Modifier = Modifier, onOpenShare: () -> Unit = {}, + onOpenList: (String) -> Unit = {}, viewModel: ListDetailViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -123,6 +128,8 @@ fun ListDetailRoute( onEditSchema = onEditSchema, onOpenWatchers = onOpenWatchers, onOpenShare = onOpenShare, + onOpenList = onOpenList, + onNewChildList = { viewModel.createChildList(onOpenList) }, snackbarHostState = snackbarHostState, modifier = modifier, ) @@ -188,6 +195,8 @@ fun ListDetailScreen( onEditSchema: () -> Unit = {}, onOpenWatchers: () -> Unit = {}, onOpenShare: () -> Unit = {}, + onOpenList: (String) -> Unit = {}, + onNewChildList: () -> Unit = {}, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { var menuOpen by remember { mutableStateOf(false) } @@ -230,6 +239,11 @@ fun ListDetailScreen( onClick = { menuOpen = false; onEditSchema() }, modifier = Modifier.testTag(ListDetailTestTags.EDIT_SCHEMA), ) + DropdownMenuItem( + text = { Text("New child list") }, + onClick = { menuOpen = false; onNewChildList() }, + modifier = Modifier.testTag(ListDetailTestTags.NEW_CHILD), + ) DropdownMenuItem( text = { Text("Watchers") }, onClick = { menuOpen = false; onOpenWatchers() }, @@ -274,6 +288,7 @@ fun ListDetailScreen( ) { Text(state.errorMessage) } else -> Column(Modifier.padding(padding)) { + Breadcrumb(ancestors = state.breadcrumb, onOpenList = onOpenList) if (!state.summary?.description.isNullOrBlank()) { Text( text = state.summary!!.description!!, @@ -294,6 +309,49 @@ fun ListDetailScreen( } } +/** + * Where this list sits in the list tree: its ancestors, root first, each one a tap + * away. Nothing is drawn for a root list. + */ +@Composable +private fun Breadcrumb(ancestors: List, onOpenList: (String) -> Unit) { + if (ancestors.isEmpty()) return + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 4.dp) + .testTag(ListDetailTestTags.BREADCRUMB), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + ancestors.forEachIndexed { index, ancestor -> + if (index > 0) { + Text( + text = "/", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = ancestor.title.ifBlank { "Untitled list" }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clickable { onOpenList(ancestor.id) } + .testTag(ListDetailTestTags.crumb(ancestor.id)), + ) + } + Text( + text = "/", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + /** * Renders rows generically against the schema: a header of field labels and one * card per row projecting each field's value. Nothing about the columns is diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt index 356f3f9..23fdde1 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt @@ -31,6 +31,8 @@ data class ListDetailUiState( val isRefreshing: Boolean = false, val refreshMessage: String? = null, val isEditingMetadata: Boolean = false, + /** Ancestors of this list, root first — empty for a root list. */ + val breadcrumb: List = emptyList(), ) { val title: String get() = summary?.title.orEmpty() val isEmpty: Boolean get() = rows.isEmpty() && !isLoading && errorMessage == null @@ -60,13 +62,16 @@ class ListDetailViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true, errorMessage = null, subscriptionRequired = false) } viewModelScope.launch { when (val result = repository.getListDetail(listId)) { - is ApiResult.Success -> _uiState.update { - it.copy( - summary = result.data.summary, - schema = result.data.schema, - rows = result.data.rows, - isLoading = false, - ) + is ApiResult.Success -> { + _uiState.update { + it.copy( + summary = result.data.summary, + schema = result.data.schema, + rows = result.data.rows, + isLoading = false, + ) + } + loadBreadcrumb(result.data.summary) } is ApiResult.Failure -> _uiState.update { it.copy( @@ -176,14 +181,62 @@ class ListDetailViewModel @Inject constructor( private fun reload() { viewModelScope.launch { when (val result = repository.getListDetail(listId)) { - is ApiResult.Success -> _uiState.update { + is ApiResult.Success -> { + _uiState.update { + it.copy( + summary = result.data.summary, + schema = result.data.schema, + rows = result.data.rows, + ) + } + loadBreadcrumb(result.data.summary) + } + is ApiResult.Failure -> Unit // Keep the existing rows; refresh already succeeded. + } + } + } + + /** + * Walks the list's parent chain so the screen can show where it sits in the + * tree. Purely navigational: a failure leaves the breadcrumb empty rather than + * putting an error in front of a list that loaded fine. + */ + private fun loadBreadcrumb(summary: ListSummary) { + val parentId = summary.parentId + if (parentId == null) { + _uiState.update { it.copy(breadcrumb = emptyList()) } + return + } + viewModelScope.launch { + when (val result = repository.getParentChain(parentId)) { + is ApiResult.Success -> _uiState.update { it.copy(breadcrumb = result.data) } + is ApiResult.Failure -> _uiState.update { it.copy(breadcrumb = emptyList()) } + } + } + } + + /** + * Creates a new list nested under this one and hands its id back so the caller + * can open it. The child starts with the same placeholder title the index uses; + * it is renamed from the detail screen like any other list. + */ + fun createChildList(onCreated: (String) -> Unit = {}) { + val parent = _uiState.value.summary ?: return + if (_uiState.value.isSaving) return + _uiState.update { it.copy(isSaving = true) } + viewModelScope.launch { + when (val result = repository.createList(title = NEW_CHILD_TITLE, parentId = parent.id)) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSaving = false) } + onCreated(result.data.id) + } + is ApiResult.Failure -> _uiState.update { it.copy( - summary = result.data.summary, - schema = result.data.schema, - rows = result.data.rows, + isSaving = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, ) } - is ApiResult.Failure -> Unit // Keep the existing rows; refresh already succeeded. } } } @@ -249,4 +302,8 @@ class ListDetailViewModel @Inject constructor( } fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + private companion object { + const val NEW_CHILD_TITLE = "New list" + } } diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt index ddf2f13..29dd5d7 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt @@ -9,6 +9,7 @@ import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder 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.Paged import com.interlinedlist.android.feature.lists.domain.RefreshResult @@ -21,6 +22,7 @@ import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.JsonObject /** * In-memory [ListsRepository] for ViewModel tests. The cache is a StateFlow so @@ -36,6 +38,7 @@ class FakeListsRepository : ListsRepository { var loadMoreResult: ApiResult> = refreshResult var searchResult: ApiResult> = ApiResult.Success(emptyList()) var createResult: ApiResult? = null + var parentChainResult: ApiResult> = ApiResult.Success(emptyList()) var updateListResult: ApiResult? = null var deleteResult: ApiResult = ApiResult.Success(Unit) var detailResult: ApiResult? = null @@ -74,6 +77,14 @@ class FakeListsRepository : ListsRepository { var refreshCount = 0 var loadMoreCount = 0 + var parentChainCount = 0 + var lastParentChainId: String? = null + var lastCreateParentId: String? = null + var lastCreateFolderId: String? = null + var lastCreateMessageId: String? = null + var lastCreateInitialRows: List>? = null + var lastCreateMetadata: JsonObject? = null + var lastCreateSource: ListSource? = null var updateListCount = 0 var updateFolderCount = 0 var deleteFolderCount = 0 @@ -113,10 +124,39 @@ class FakeListsRepository : ListsRepository { override suspend fun searchLists(query: String, limit: Int): ApiResult> = searchResult - override suspend fun createList(title: String, description: String?, isPublic: Boolean): ApiResult = - createResult ?: ApiResult.Success( - ListSummary("new", title, description, 0, null, isPublic, null), + override suspend fun createList( + title: String, + description: String?, + isPublic: Boolean, + parentId: String?, + folderId: String?, + messageId: String?, + initialRows: List>?, + metadata: JsonObject?, + source: ListSource?, + ): ApiResult { + lastCreateParentId = parentId + lastCreateFolderId = folderId + lastCreateMessageId = messageId + lastCreateInitialRows = initialRows + lastCreateMetadata = metadata + lastCreateSource = source + return createResult ?: ApiResult.Success( + ListSummary("new", title, description, 0, folderId, isPublic, null, parentId), ) + } + + override suspend fun createListFromMessage( + messageId: String, + title: String, + description: String?, + ): ApiResult = createList(title = title, description = description, messageId = messageId) + + override suspend fun getParentChain(parentId: String): ApiResult> { + parentChainCount++ + lastParentChainId = parentId + return parentChainResult + } override suspend fun updateList( id: String, diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/CreateListRequestTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/CreateListRequestTest.kt new file mode 100644 index 0000000..3477e3b --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/CreateListRequestTest.kt @@ -0,0 +1,137 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import org.junit.Test + +/** + * The wire contract for `POST /api/lists`. The API accepts more creation options + * than the app used to send, and every one of them must be *omitted* — not sent + * as `null` — when the caller does not supply it, so existing callers keep + * producing exactly the body they produced before. + */ +class CreateListRequestTest { + + // Same configuration the app's Retrofit converter uses (see NetworkModule). + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + private fun encode(request: CreateListRequest): JsonObject = + json.encodeToJsonElement(request).jsonObject + + @Test + fun `omits every optional creation option when it is not supplied`() { + val body = encode(CreateListRequest(title = "Books")) + + assertThat(body.keys).containsExactly("title") + assertThat(body.keys).containsNoneOf("parentId", "folderId", "messageId") + assertThat(body.keys).containsNoneOf("initialRows", "metadata", "source", "schema") + } + + @Test + fun `omits the options a caller explicitly passes as null`() { + val body = encode( + CreateListRequest( + title = "Books", + description = null, + parentId = null, + folderId = null, + messageId = null, + initialRows = null, + metadata = null, + source = null, + ), + ) + + assertThat(body.keys).containsExactly("title") + } + + @Test + fun `carries every creation option the API accepts when supplied`() { + val body = encode( + CreateListRequest( + title = "Books", + description = "My reading backlog.", + isPublic = true, + parentId = "lst_parent", + folderId = "fld_1", + messageId = "msg_42", + source = "github", + metadata = buildJsonObject { put("colour", "blue") }, + initialRows = listOf(buildJsonObject { put("title", "Dune") }), + schema = buildJsonObject { put("name", "Books") }, + ), + ) + + assertThat(body["title"]).isEqualTo(JsonPrimitive("Books")) + assertThat(body["description"]).isEqualTo(JsonPrimitive("My reading backlog.")) + assertThat(body["isPublic"]).isEqualTo(JsonPrimitive(true)) + assertThat(body["parentId"]).isEqualTo(JsonPrimitive("lst_parent")) + assertThat(body["folderId"]).isEqualTo(JsonPrimitive("fld_1")) + assertThat(body["messageId"]).isEqualTo(JsonPrimitive("msg_42")) + assertThat(body["source"]).isEqualTo(JsonPrimitive("github")) + assertThat(body["metadata"]?.jsonObject?.get("colour")).isEqualTo(JsonPrimitive("blue")) + assertThat(body["initialRows"]?.jsonArray).hasSize(1) + assertThat(body["schema"]?.jsonObject?.get("name")).isEqualTo(JsonPrimitive("Books")) + } + + @Test + fun `initialRows serialise as the row payloads the list-data API expects`() { + val row = buildJsonObject { + put("title", "Dune") + put("year", 1965) + put("read", false) + } + + val initialRows = encode( + CreateListRequest(title = "Books", initialRows = listOf(row)), + )["initialRows"]!!.jsonArray + + // `POST /api/lists/{id}/data` sends exactly this per-row shape under `data`: + // a flat object keyed by schema field key. + val rowWrite = json.encodeToJsonElement(RowWriteRequest(row)).jsonObject["data"] + assertThat(initialRows).isEqualTo(JsonArray(listOf(rowWrite!!))) + } + + @Test + fun `metadata round-trips an arbitrary JSON object without losing keys`() { + val metadata = buildJsonObject { + put("colour", "blue") + put("pinned", true) + put("rank", 3) + put("archivedAt", JsonPrimitive(null as String?)) + put("tags", buildJsonArray { add(JsonPrimitive("a")); add(JsonPrimitive("b")) }) + put( + "nested", + buildJsonObject { + put("deep", buildJsonObject { put("key", "value") }) + }, + ) + } + + val encoded = json.encodeToString(CreateListRequest.serializer(), CreateListRequest("Books", metadata = metadata)) + val decoded = json.decodeFromString(CreateListRequest.serializer(), encoded) + + assertThat(decoded.metadata).isEqualTo(metadata) + // Every key survives the round-trip, including explicit nulls and nesting. + assertThat(decoded.metadata!!.keys) + .containsExactly("colour", "pinned", "rank", "archivedAt", "tags", "nested") + assertThat(decoded.metadata!!["nested"]?.jsonObject?.get("deep")?.jsonObject?.get("key")) + .isEqualTo(JsonPrimitive("value")) + assertThat(encoded).contains("\"archivedAt\":null") + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt new file mode 100644 index 0000000..8d8c308 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt @@ -0,0 +1,266 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.local.CachedListEntity +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.interlinedlist.android.feature.lists.domain.ListSource +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Creation options (`parentId`, `folderId`, `messageId`, `initialRows`, + * `metadata`, `source`) and parent-chain resolution, driven through the real + * Retrofit/OkHttp stack so the assertions are about the bytes that reach the API. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultListsRepositoryCreationTest { + + private lateinit var server: MockWebServer + private lateinit var api: ListsApi + private lateinit var dao: FakeCreationDao + private lateinit var repository: DefaultListsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(ListsApi::class.java) + dao = FakeCreationDao() + repository = DefaultListsRepository(api, dao, json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + private fun RecordedRequest.bodyJson(): JsonObject = + json.parseToJsonElement(body.readUtf8()).jsonObject + + private fun enqueueCreated(id: String = "lst_new", extra: String = "") { + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "message": "created", "data": { "id": "$id", "title": "Books"$extra } }""", + ), + ) + } + + @Test + fun `createList sends only the fields the caller supplied`() = runTest(dispatcher) { + enqueueCreated() + + repository.createList(title = "Books", description = "Backlog", isPublic = false) + + val body = server.takeRequest().bodyJson() + assertThat(body["title"]).isEqualTo(JsonPrimitive("Books")) + assertThat(body["description"]).isEqualTo(JsonPrimitive("Backlog")) + // Untouched options must not appear at all — not even as explicit nulls. + assertThat(body.keys).containsNoneOf("parentId", "folderId", "messageId") + assertThat(body.keys).containsNoneOf("initialRows", "metadata", "source") + } + + @Test + fun `createList sends every creation option the API accepts`() = runTest(dispatcher) { + enqueueCreated(extra = """, "parentId": "lst_parent" """) + + val result = repository.createList( + title = "Books", + description = "Backlog", + isPublic = true, + parentId = "lst_parent", + folderId = "fld_1", + messageId = "msg_42", + initialRows = listOf(mapOf("title" to "Dune", "year" to "1965")), + metadata = buildJsonObject { put("colour", "blue") }, + source = ListSource.GITHUB, + ) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val body = server.takeRequest().bodyJson() + assertThat(body["parentId"]).isEqualTo(JsonPrimitive("lst_parent")) + assertThat(body["folderId"]).isEqualTo(JsonPrimitive("fld_1")) + assertThat(body["messageId"]).isEqualTo(JsonPrimitive("msg_42")) + assertThat(body["isPublic"]).isEqualTo(JsonPrimitive(true)) + assertThat(body["source"]).isEqualTo(JsonPrimitive("github")) + assertThat(body["metadata"]?.jsonObject?.get("colour")).isEqualTo(JsonPrimitive("blue")) + // Rows go out in the same shape `POST /api/lists/{id}/data` uses: a flat + // object per row, keyed by schema field key. + val rows = body["initialRows"]!!.jsonArray + assertThat(rows).hasSize(1) + assertThat(rows[0].jsonObject["title"]).isEqualTo(JsonPrimitive("Dune")) + assertThat(rows[0].jsonObject["year"]).isEqualTo(JsonPrimitive("1965")) + // The created child is cached with its parent so the breadcrumb can resolve. + assertThat((result as ApiResult.Success).data.parentId).isEqualTo("lst_parent") + } + + @Test + fun `createList drops blank row values like the row write path does`() = runTest(dispatcher) { + enqueueCreated() + + repository.createList( + title = "Books", + initialRows = listOf(mapOf("title" to "Dune", "notes" to " ")), + ) + + val rows = server.takeRequest().bodyJson()["initialRows"]!!.jsonArray + assertThat(rows[0].jsonObject.keys).containsExactly("title") + } + + @Test + fun `createListFromMessage posts the message id with the message as the description`() = + runTest(dispatcher) { + enqueueCreated(extra = """, "messageId": "msg_42" """) + + repository.createListFromMessage( + messageId = "msg_42", + title = "Reading picks", + description = "The message body", + ) + + val body = server.takeRequest().bodyJson() + assertThat(body["messageId"]).isEqualTo(JsonPrimitive("msg_42")) + assertThat(body["title"]).isEqualTo(JsonPrimitive("Reading picks")) + assertThat(body["description"]).isEqualTo(JsonPrimitive("The message body")) + } + + @Test + fun `getParentChain walks a multi-level chain and returns it root first`() = runTest(dispatcher) { + // parent → grandparent → root, each fetched in turn. + server.enqueue(MockResponse().setBody("""{ "data": { "id": "p", "title": "Parent", "parentId": "gp" } }""")) + server.enqueue(MockResponse().setBody("""{ "data": { "id": "gp", "title": "Grandparent", "parentId": "root" } }""")) + server.enqueue(MockResponse().setBody("""{ "data": { "id": "root", "title": "Root" } }""")) + + val result = repository.getParentChain("p") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val chain = (result as ApiResult.Success).data + assertThat(chain.map { it.id }).containsExactly("root", "gp", "p").inOrder() + assertThat(chain.map { it.title }).containsExactly("Root", "Grandparent", "Parent").inOrder() + assertThat(server.takeRequest().path).isEqualTo("/api/lists/p") + assertThat(server.takeRequest().path).isEqualTo("/api/lists/gp") + assertThat(server.takeRequest().path).isEqualTo("/api/lists/root") + } + + @Test + fun `getParentChain uses a nested parent object instead of another request`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "data": { + "id": "p", "title": "Parent", "parentId": "root", + "parent": { "id": "root", "title": "Root", "parentId": null } + } + } + """.trimIndent(), + ), + ) + + val result = repository.getParentChain("p") + + val chain = (result as ApiResult.Success).data + assertThat(chain.map { it.id }).containsExactly("root", "p").inOrder() + // The nested `parent` the server sent spares a round trip. + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `getParentChain stops on a cycle`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "data": { "id": "a", "title": "A", "parentId": "b" } }""")) + server.enqueue(MockResponse().setBody("""{ "data": { "id": "b", "title": "B", "parentId": "a" } }""")) + + val chain = (repository.getParentChain("a") as ApiResult.Success).data + + assertThat(chain.map { it.id }).containsExactly("b", "a").inOrder() + assertThat(server.requestCount).isEqualTo(2) + } + + @Test + fun `getParentChain truncates when an ancestor cannot be fetched`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "data": { "id": "p", "title": "Parent", "parentId": "gp" } }""")) + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "not found", "code": "not_found" }""")) + + val chain = (repository.getParentChain("p") as ApiResult.Success).data + + // A broken link shortens the breadcrumb; it does not fail the screen. + assertThat(chain.map { it.id }).containsExactly("p") + } + + @Test + fun `getParentChain reports the failure when nothing could be resolved`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "not found", "code": "not_found" }""")) + + assertThat(repository.getParentChain("p")).isInstanceOf(ApiResult.Failure::class.java) + } + + @Test + fun `getListDetail exposes the parent id so the breadcrumb can be resolved`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "data": { "id": "c", "title": "Child", "parentId": "p" } }""")) + server.enqueue(MockResponse().setBody("""[ { "key": "title", "type": "text" } ]""")) + server.enqueue(MockResponse().setBody("""{ "data": [] }""")) + + val detail = (repository.getListDetail("c") as ApiResult.Success).data + + assertThat(detail.summary.parentId).isEqualTo("p") + // The cache keeps the parent link too, so an offline open still shows it. + assertThat(dao.cached.single().parentId).isEqualTo("p") + } +} + +/** In-memory [ListDao] backed by a StateFlow, for JVM repository tests. */ +private class FakeCreationDao : ListDao { + private val state = MutableStateFlow>(emptyList()) + + val cached: List get() = state.value + + override fun observeLists(): Flow> = state + + override suspend fun upsertAll(lists: List) { + val byId = state.value.associateBy { it.id }.toMutableMap() + lists.forEach { byId[it.id] = it } + state.value = byId.values.toList() + } + + override suspend fun upsert(list: CachedListEntity) = upsertAll(listOf(list)) + + override suspend fun deleteById(id: String) { + state.value = state.value.filterNot { it.id == id } + } + + override suspend fun clear() { + state.value = emptyList() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt index 14701ab..a76bb5c 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt @@ -235,4 +235,93 @@ class ListDetailViewModelTest { assertThat(vm.uiState.value.errorMessage).isNotNull() assertThat(vm.uiState.value.refreshMessage).isNull() } + + // --- Parent chain (breadcrumb) + child lists ----------------------------- + + private fun child(parentId: String?) = ListDetail( + summary = ListSummary("L1", "Reading", "Books", 0, null, false, null, parentId), + schema = schema, + rows = emptyList(), + ) + + @Test + fun `resolves the breadcrumb for a child list, root first`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(child(parentId = "P1")) + parentChainResult = ApiResult.Success( + listOf( + ListSummary("ROOT", "Root", null, 0, null, false, null, null), + ListSummary("P1", "Parent", null, 0, null, false, null, "ROOT"), + ), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(repo.lastParentChainId).isEqualTo("P1") + assertThat(vm.uiState.value.breadcrumb.map { it.id }).containsExactly("ROOT", "P1").inOrder() + } + + @Test + fun `does not resolve a breadcrumb for a root list`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(child(parentId = null)) } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(repo.parentChainCount).isEqualTo(0) + assertThat(vm.uiState.value.breadcrumb).isEmpty() + } + + @Test + fun `a failed breadcrumb leaves the list usable`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(child(parentId = "P1")) + parentChainResult = FakeListsRepository.subscriptionFailure() + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.breadcrumb).isEmpty() + assertThat(vm.uiState.value.errorMessage).isNull() + assertThat(vm.uiState.value.summary).isNotNull() + } + + @Test + fun `createChildList creates the new list under the current one`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(child(parentId = null)) + createResult = ApiResult.Success( + ListSummary("L2", "New list", null, 0, null, false, null, "L1"), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + var opened: String? = null + vm.createChildList { opened = it } + advanceUntilIdle() + + assertThat(repo.lastCreateParentId).isEqualTo("L1") + assertThat(opened).isEqualTo("L2") + assertThat(vm.uiState.value.isSaving).isFalse() + } + + @Test + fun `createChildList surfaces a failure without navigating`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(child(parentId = null)) + createResult = FakeListsRepository.subscriptionFailure() + } + val vm = viewModel(repo) + advanceUntilIdle() + + var opened: String? = null + vm.createChildList { opened = it } + advanceUntilIdle() + + assertThat(opened).isNull() + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + assertThat(vm.uiState.value.isSaving).isFalse() + } }