feat: hydrate board label chip colors from API
This commit is contained in:
@@ -97,7 +97,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
|
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
|
||||||
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
|
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
|
||||||
- Long-pressing any of the buttons must show a tooltip with the button name.
|
- Long-pressing any of the buttons must show a tooltip with the button name.
|
||||||
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, card rendering (title/tags/due date locale formatting and expiry color), cross-page card selection, page-scoped select-all, move dialog with list selector, two-step delete confirmation, mutation guards while in progress, and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`. Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night resource variants so dark mode uses light icon fills automatically. Startup blocking dialogs are shown for missing board id and missing session.
|
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, card rendering (title/tags/due date locale formatting and expiry color), cross-page card selection, page-scoped select-all, move dialog with list selector, two-step delete confirmation, mutation guards while in progress, and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`. Label chip border colors are hydrated from the Kan.bn `Get a label by public ID` endpoint (`colourCode`) and cached in-memory by `BoardDetailRepository` so each label color is fetched only once per app process. Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night resource variants so dark mode uses light icon fills automatically. Startup blocking dialogs are shown for missing board id and missing session.
|
||||||
|
|
||||||
**Card detail view**
|
**Card detail view**
|
||||||
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
|
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
|
||||||
@@ -132,5 +132,5 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- It is preferable to use standard Android libraries and tools whenever possible.
|
- It is preferable to use standard Android libraries and tools whenever possible.
|
||||||
- If a task would be better served by or can only be done with external libraries then it is allowed to use them but you MUST ask the user for permission to add the library to the project first.
|
- If a task would be better served by or can only be done with external libraries then it is allowed to use them but you MUST ask the user for permission to add the library to the project first.
|
||||||
- The documentation for the Kan.bn API is available here https://docs.kan.bn/api-reference/introduction
|
- The documentation for the Kan.bn API is available here https://docs.kan.bn/api-reference/introduction
|
||||||
- Never commit or push code unless explicitely prompted to do so.
|
- Never push code unless explicitely prompted to do so.
|
||||||
- After every run, update this file as needed to reflect the changes made.
|
- After every run, update this file as needed to reflect the changes made.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.ArrayDeque
|
import java.util.ArrayDeque
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -148,6 +149,22 @@ class BoardDetailFlowTest {
|
|||||||
onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.BLACK)))
|
onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.BLACK)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalidTagColorFallsBackToOnSurfaceColor() {
|
||||||
|
defaultDataSource.currentDetail = detailWithInvalidTagColor()
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
var expectedColor: Int? = null
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
expectedColor = MaterialColors.getColor(
|
||||||
|
activity.findViewById(android.R.id.content),
|
||||||
|
com.google.android.material.R.attr.colorOnSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onView(withText("Backend")).check(matches(withChipStrokeColor(expectedColor ?: Color.DKGRAY)))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun dueDateUsesSystemLocaleFormatting() {
|
fun dueDateUsesSystemLocaleFormatting() {
|
||||||
Locale.setDefault(Locale.FRANCE)
|
Locale.setDefault(Locale.FRANCE)
|
||||||
@@ -571,6 +588,21 @@ class BoardDetailFlowTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun withChipStrokeColor(expectedColor: Int): Matcher<View> {
|
||||||
|
return object : TypeSafeMatcher<View>() {
|
||||||
|
override fun describeTo(description: Description) {
|
||||||
|
description.appendText("with chip stroke color: $expectedColor")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun matchesSafely(item: View): Boolean {
|
||||||
|
if (item !is Chip) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return item.chipStrokeColor?.defaultColor == expectedColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun awaitCondition(timeoutMs: Long = 4_000L, condition: () -> Boolean) {
|
private fun awaitCondition(timeoutMs: Long = 4_000L, condition: () -> Boolean) {
|
||||||
val instrumentation = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
|
val instrumentation = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
@@ -757,6 +789,25 @@ class BoardDetailFlowTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun detailWithInvalidTagColor(): BoardDetail {
|
||||||
|
return detailOneList().copy(
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Card 1",
|
||||||
|
tags = listOf(BoardTagSummary("tag-1", "Backend", "bad-color")),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun detailWithCardTitle(title: String): BoardDetail {
|
fun detailWithCardTitle(title: String): BoardDetail {
|
||||||
return detailOneList().copy(
|
return detailOneList().copy(
|
||||||
lists = listOf(
|
lists = listOf(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import org.junit.runner.RunWith
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||||
@@ -199,5 +200,13 @@ class BoardsFlowTest {
|
|||||||
boards.removeAll { it.id == boardId }
|
boards.removeAll { it.id == boardId }
|
||||||
return BoardsApiResult.Success(Unit)
|
return BoardsApiResult.Success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLabelByPublicId(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
labelId: String,
|
||||||
|
): BoardsApiResult<LabelDetail> {
|
||||||
|
return BoardsApiResult.Failure("Not needed in boards flow tests")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import space.hackenslacker.kanbn4droid.app.auth.AuthFailureReason
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@@ -200,5 +201,13 @@ class LoginFlowTest {
|
|||||||
private val result: AuthResult,
|
private val result: AuthResult,
|
||||||
) : KanbnApiClient {
|
) : KanbnApiClient {
|
||||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result
|
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result
|
||||||
|
|
||||||
|
override suspend fun getLabelByPublicId(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
labelId: String,
|
||||||
|
): space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult<LabelDetail> {
|
||||||
|
return space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult.Failure("Not needed in login tests")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,17 @@ interface KanbnApiClient {
|
|||||||
suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
|
suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
|
||||||
return BoardsApiResult.Failure("Card deletion is not implemented.")
|
return BoardsApiResult.Failure("Card deletion is not implemented.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getLabelByPublicId(baseUrl: String, apiKey: String, labelId: String): BoardsApiResult<LabelDetail> {
|
||||||
|
return BoardsApiResult.Failure("Label detail is not implemented.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class LabelDetail(
|
||||||
|
val id: String,
|
||||||
|
val colorHex: String,
|
||||||
|
)
|
||||||
|
|
||||||
class HttpKanbnApiClient : KanbnApiClient {
|
class HttpKanbnApiClient : KanbnApiClient {
|
||||||
|
|
||||||
override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
|
override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
|
||||||
@@ -304,6 +313,29 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLabelByPublicId(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
labelId: String,
|
||||||
|
): BoardsApiResult<LabelDetail> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
request(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/labels/$labelId",
|
||||||
|
method = "GET",
|
||||||
|
apiKey = apiKey,
|
||||||
|
) { code, body ->
|
||||||
|
if (code in 200..299) {
|
||||||
|
parseLabelDetail(body, labelId)
|
||||||
|
?.let { BoardsApiResult.Success(it) }
|
||||||
|
?: BoardsApiResult.Failure("Malformed label detail response.")
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(body, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T> request(
|
private fun <T> request(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -504,6 +536,23 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
return BoardDetail(id = boardId, title = boardTitle, lists = lists)
|
return BoardDetail(id = boardId, title = boardTitle, lists = lists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseLabelDetail(body: String, fallbackId: String): LabelDetail? {
|
||||||
|
if (body.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val root = parseJsonObject(body) ?: return null
|
||||||
|
val data = root["data"] as? Map<*, *>
|
||||||
|
val label = (data?.get("label") as? Map<*, *>)
|
||||||
|
?: data
|
||||||
|
?: (root["label"] as? Map<*, *>)
|
||||||
|
?: root
|
||||||
|
|
||||||
|
val id = extractId(label).ifBlank { fallbackId }
|
||||||
|
val colorHex = extractString(label, "colourCode", "colorCode", "colorHex", "color", "hex")
|
||||||
|
return LabelDetail(id = id, colorHex = colorHex)
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
|
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
|
||||||
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
|
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
|
||||||
val id = extractId(rawList)
|
val id = extractId(rawList)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ class BoardDetailRepository(
|
|||||||
private val apiClient: KanbnApiClient,
|
private val apiClient: KanbnApiClient,
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
|
private val labelColorCache = mutableMapOf<String, String>()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again."
|
const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again."
|
||||||
}
|
}
|
||||||
@@ -33,7 +36,13 @@ class BoardDetailRepository(
|
|||||||
baseUrl = session.baseUrl,
|
baseUrl = session.baseUrl,
|
||||||
apiKey = session.apiKey,
|
apiKey = session.apiKey,
|
||||||
boardId = normalizedBoardId,
|
boardId = normalizedBoardId,
|
||||||
)
|
).mapSuccess { detail ->
|
||||||
|
hydrateLabelColors(
|
||||||
|
detail = detail,
|
||||||
|
baseUrl = session.baseUrl,
|
||||||
|
apiKey = session.apiKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
|
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
|
||||||
@@ -192,6 +201,63 @@ class BoardDetailRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun hydrateLabelColors(
|
||||||
|
detail: BoardDetail,
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
): BoardDetail {
|
||||||
|
val missingColorIds = detail.lists
|
||||||
|
.asSequence()
|
||||||
|
.flatMap { list -> list.cards.asSequence() }
|
||||||
|
.flatMap { card -> card.tags.asSequence() }
|
||||||
|
.map { it.id.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.filter { !labelColorCache.containsKey(it) }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
missingColorIds.forEach { labelId ->
|
||||||
|
val colorHex = when (val labelResult = apiClient.getLabelByPublicId(baseUrl, apiKey, labelId)) {
|
||||||
|
is BoardsApiResult.Success -> normalizeColorHex(labelResult.value)
|
||||||
|
is BoardsApiResult.Failure -> ""
|
||||||
|
}
|
||||||
|
if (colorHex.isNotBlank()) {
|
||||||
|
labelColorCache[labelId] = colorHex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hydratedLists = detail.lists.map { list ->
|
||||||
|
list.copy(
|
||||||
|
cards = list.cards.map { card ->
|
||||||
|
card.copy(
|
||||||
|
tags = card.tags.map { tag ->
|
||||||
|
val cached = labelColorCache[tag.id.trim()].orEmpty()
|
||||||
|
val merged = if (cached.isNotBlank()) cached else tag.colorHex
|
||||||
|
if (merged == tag.colorHex) {
|
||||||
|
tag
|
||||||
|
} else {
|
||||||
|
tag.copy(colorHex = merged)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (hydratedLists == detail.lists) detail else detail.copy(lists = hydratedLists)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeColorHex(label: LabelDetail): String {
|
||||||
|
return label.colorHex.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T, R> BoardsApiResult<T>.mapSuccess(transform: suspend (T) -> R): BoardsApiResult<R> {
|
||||||
|
return when (this) {
|
||||||
|
is BoardsApiResult.Success -> BoardsApiResult.Success(transform(this.value))
|
||||||
|
is BoardsApiResult.Failure -> BoardsApiResult.Failure(this.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private data class SessionSnapshot(
|
private data class SessionSnapshot(
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
val apiKey: String,
|
val apiKey: String,
|
||||||
|
|||||||
@@ -341,6 +341,84 @@ class HttpKanbnApiClientBoardDetailParsingTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getLabelByPublicIdParsesColourCodeFromWrappedPayload() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/labels/label-1",
|
||||||
|
status = 200,
|
||||||
|
responseBody =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"label": {
|
||||||
|
"public_id": "label-1",
|
||||||
|
"colourCode": "#A1B2C3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().getLabelByPublicId(server.baseUrl, "api-key", "label-1")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val detail = (result as BoardsApiResult.Success<*>).value as LabelDetail
|
||||||
|
assertEquals("label-1", detail.id)
|
||||||
|
assertEquals("#A1B2C3", detail.colorHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getLabelByPublicIdUsesFallbackIdWhenPayloadHasNoId() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/labels/label-fallback",
|
||||||
|
status = 200,
|
||||||
|
responseBody =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"label": {
|
||||||
|
"colorCode": "#ABCDEF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().getLabelByPublicId(server.baseUrl, "api-key", "label-fallback")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val detail = (result as BoardsApiResult.Success<*>).value as LabelDetail
|
||||||
|
assertEquals("label-fallback", detail.id)
|
||||||
|
assertEquals("#ABCDEF", detail.colorHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getLabelByPublicIdFailureUsesServerMessageAndFallback() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/labels/label-msg",
|
||||||
|
status = 404,
|
||||||
|
responseBody = """{"message":"Label not found"}""",
|
||||||
|
)
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/labels/label-fallback",
|
||||||
|
status = 500,
|
||||||
|
responseBody = "{}",
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = HttpKanbnApiClient()
|
||||||
|
val withMessage = client.getLabelByPublicId(server.baseUrl, "api", "label-msg")
|
||||||
|
val fallback = client.getLabelByPublicId(server.baseUrl, "api", "label-fallback")
|
||||||
|
|
||||||
|
assertTrue(withMessage is BoardsApiResult.Failure)
|
||||||
|
assertEquals("Label not found", (withMessage as BoardsApiResult.Failure).message)
|
||||||
|
assertTrue(fallback is BoardsApiResult.Failure)
|
||||||
|
assertEquals("Server error: 500", (fallback as BoardsApiResult.Failure).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private data class CapturedRequest(
|
private data class CapturedRequest(
|
||||||
val method: String,
|
val method: String,
|
||||||
val path: String,
|
val path: String,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.junit.Test
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||||
@@ -99,6 +100,95 @@ class BoardDetailRepositoryTest {
|
|||||||
assertEquals("Server says no", (result as BoardsApiResult.Failure).message)
|
assertEquals("Server says no", (result as BoardsApiResult.Failure).message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getBoardDetailFetchesLabelColorCodesAndCachesThem() = runTest {
|
||||||
|
val detailWithTags = BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Card 1",
|
||||||
|
tags = listOf(
|
||||||
|
BoardTagSummary(id = "label-1", name = "Urgent", colorHex = ""),
|
||||||
|
BoardTagSummary(id = "label-2", name = "Backend", colorHex = "#000000"),
|
||||||
|
),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-2",
|
||||||
|
title = "Card 2",
|
||||||
|
tags = listOf(
|
||||||
|
BoardTagSummary(id = "label-1", name = "Urgent", colorHex = ""),
|
||||||
|
),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val apiClient = FakeBoardDetailApiClient().apply {
|
||||||
|
boardDetailResult = BoardsApiResult.Success(detailWithTags)
|
||||||
|
labelByIdResults = mapOf(
|
||||||
|
"label-1" to BoardsApiResult.Success(LabelDetail("label-1", "#112233")),
|
||||||
|
"label-2" to BoardsApiResult.Success(LabelDetail("label-2", "#445566")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val repository = createRepository(apiClient = apiClient)
|
||||||
|
|
||||||
|
val first = repository.getBoardDetail("board-1")
|
||||||
|
val second = repository.getBoardDetail("board-1")
|
||||||
|
|
||||||
|
assertTrue(first is BoardsApiResult.Success<*>)
|
||||||
|
assertTrue(second is BoardsApiResult.Success<*>)
|
||||||
|
val firstDetail = (first as BoardsApiResult.Success<BoardDetail>).value
|
||||||
|
val secondDetail = (second as BoardsApiResult.Success<BoardDetail>).value
|
||||||
|
assertEquals("#112233", firstDetail.lists[0].cards[0].tags[0].colorHex)
|
||||||
|
assertEquals("#445566", firstDetail.lists[0].cards[0].tags[1].colorHex)
|
||||||
|
assertEquals("#112233", firstDetail.lists[0].cards[1].tags[0].colorHex)
|
||||||
|
assertEquals("#112233", secondDetail.lists[0].cards[0].tags[0].colorHex)
|
||||||
|
assertEquals("#445566", secondDetail.lists[0].cards[0].tags[1].colorHex)
|
||||||
|
assertEquals(listOf("label-1", "label-2"), apiClient.getLabelByPublicIdCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getBoardDetailKeepsOriginalColorWhenLabelLookupFails() = runTest {
|
||||||
|
val detailWithTag = BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Card 1",
|
||||||
|
tags = listOf(BoardTagSummary(id = "label-1", name = "Urgent", colorHex = "#ABCDEF")),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val apiClient = FakeBoardDetailApiClient().apply {
|
||||||
|
boardDetailResult = BoardsApiResult.Success(detailWithTag)
|
||||||
|
labelByIdResults = mapOf("label-1" to BoardsApiResult.Failure("Server unavailable"))
|
||||||
|
}
|
||||||
|
val repository = createRepository(apiClient = apiClient)
|
||||||
|
|
||||||
|
val result = repository.getBoardDetail("board-1")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val detail = (result as BoardsApiResult.Success<BoardDetail>).value
|
||||||
|
assertEquals("#ABCDEF", detail.lists[0].cards[0].tags[0].colorHex)
|
||||||
|
assertEquals(listOf("label-1"), apiClient.getLabelByPublicIdCalls)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun renameListPropagatesApiFailureMessageUnchanged() = runTest {
|
fun renameListPropagatesApiFailureMessageUnchanged() = runTest {
|
||||||
val apiClient = FakeBoardDetailApiClient().apply {
|
val apiClient = FakeBoardDetailApiClient().apply {
|
||||||
@@ -366,6 +456,7 @@ class BoardDetailRepositoryTest {
|
|||||||
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
||||||
var moveOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
|
var moveOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
|
||||||
var deleteOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
|
var deleteOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
|
||||||
|
var labelByIdResults: Map<String, BoardsApiResult<LabelDetail>> = emptyMap()
|
||||||
|
|
||||||
var listWorkspacesCalls: Int = 0
|
var listWorkspacesCalls: Int = 0
|
||||||
var lastBoardId: String? = null
|
var lastBoardId: String? = null
|
||||||
@@ -374,6 +465,7 @@ class BoardDetailRepositoryTest {
|
|||||||
var movedCardIds: MutableList<String> = mutableListOf()
|
var movedCardIds: MutableList<String> = mutableListOf()
|
||||||
var deletedCardIds: MutableList<String> = mutableListOf()
|
var deletedCardIds: MutableList<String> = mutableListOf()
|
||||||
var lastMoveTargetListId: String? = null
|
var lastMoveTargetListId: String? = null
|
||||||
|
var getLabelByPublicIdCalls: MutableList<String> = mutableListOf()
|
||||||
|
|
||||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
||||||
|
|
||||||
@@ -425,6 +517,15 @@ class BoardDetailRepositoryTest {
|
|||||||
return deleteOutcomes[cardId] ?: BoardsApiResult.Success(Unit)
|
return deleteOutcomes[cardId] ?: BoardsApiResult.Success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLabelByPublicId(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
labelId: String,
|
||||||
|
): BoardsApiResult<LabelDetail> {
|
||||||
|
getLabelByPublicIdCalls += labelId
|
||||||
|
return labelByIdResults[labelId] ?: BoardsApiResult.Failure("Missing fake label")
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun listBoards(
|
override suspend fun listBoards(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.junit.Test
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
|
|
||||||
class BoardsRepositoryTest {
|
class BoardsRepositoryTest {
|
||||||
@@ -250,5 +251,13 @@ class BoardsRepositoryTest {
|
|||||||
lastDeletedId = boardId
|
lastDeletedId = boardId
|
||||||
return deleteBoardResult
|
return deleteBoardResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLabelByPublicId(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
labelId: String,
|
||||||
|
): BoardsApiResult<LabelDetail> {
|
||||||
|
return BoardsApiResult.Failure("Not needed in boards tests")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.junit.Test
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -194,5 +195,13 @@ class BoardsViewModelTest {
|
|||||||
lastDeletedId = boardId
|
lastDeletedId = boardId
|
||||||
return deleteBoardResult
|
return deleteBoardResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLabelByPublicId(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
labelId: String,
|
||||||
|
): BoardsApiResult<LabelDetail> {
|
||||||
|
return BoardsApiResult.Failure("Not needed in boards tests")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user