Keep catalogue outcomes and recovery consistent across views

This commit is contained in:
justinzeus 2026-11-05 18:32:09 +01:00
parent c1559e2513
commit b1b15b384b
21 changed files with 340 additions and 216 deletions

View file

@ -14,7 +14,8 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.json.JSONArray
@ -64,23 +65,23 @@ class DiscoverCaptureTest {
rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
recordTypography(directory, name, state)
recordTypography(directory, name)
}
private fun recordTypography(directory: File, name: String, state: DiscoverState) {
private fun recordTypography(directory: File, name: String) {
val rows = JSONArray()
val labels = listOfNotNull(state.hero?.title, "Request", "For", "Justin (you)", "Hidden picks", "Netflix", "Popular movies")
labels.forEach { text ->
rule.onAllNodesWithText(text, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
.put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
}
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
if (SemanticsActions.GetTextLayoutResult in node.config) node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
val text = layout.layoutInput.text.text
val lines = JSONArray()
repeat(layout.lineCount) { index -> lines.put(text.substring(layout.getLineStart(index), layout.getLineEnd(index, visibleEnd = true))) }
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount).put("lineTexts", lines)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
.put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
}
}
File(directory, "$name-type-metrics.json").writeText(rows.toString(2))

View file

@ -117,6 +117,50 @@ class DiscoverKeysTest {
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
}
@Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() {
operations.next = RequestPhase.NotConfirmed
replay("Request a pick for yourself")
press(Key.Back, Key.DirectionCenter)
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
operations.next = RequestPhase.Receipt
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(1, operations.checked)
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
}
@Test fun requestingForAnotherPersonKeepsYourOwnRequestedAction() {
replay("Request a pick for yourself")
press(Key.DirectionCenter)
press(Key.DirectionRight, Key.DirectionCenter)
press(Key.DirectionCenter, Key.DirectionCenter)
assertEquals(setOf("justin", "mindel"), state.requests.getValue(state.title!!.key).map { it.requester }.toSet())
press(Key.DirectionCenter)
press(Key.DirectionLeft, Key.DirectionCenter)
assertTrue(shell.requestsVisible)
assertEquals(2, operations.sent.size)
}
@Test fun choosingAnUncertainRecipientAgainNeverResubmits() {
operations.next = RequestPhase.NotConfirmed
replay("Open Discover")
press(Key.DirectionCenter)
press(Key.DirectionRight, Key.DirectionCenter)
press(Key.DirectionDown, Key.DirectionCenter)
press(Key.DirectionCenter)
assertEquals("shared", operations.sent.single().requester)
press(Key.Back, Key.DirectionCenter)
press(Key.DirectionDown, Key.DirectionCenter)
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
operations.next = RequestPhase.Receipt
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(1, operations.checked)
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
}
@Test fun unknownSeriesSeasonsNeverSubmitAndBackRestoresOpener() {
replay("Open Discover")
rule.runOnIdle { state.openTitle(snapshot.picks.first { it.type == "tv" }); state.openRequest(false) }
@ -155,6 +199,37 @@ class DiscoverKeysTest {
assertEquals(GridTail.ShowMore, state.collection!!.tail)
}
@Test fun collectionRetryReadsNewTitlesAndOpensTheFirstAppendedTitle() {
shell.pickerVisible = false
val lane = state.lanes.first { it.id == "trending" }
state.openCollection(lane.copy(titles = lane.fullTitles.take(6), fullTitles = lane.fullTitles.take(6), tail = DiscoverTail.Retry))
val grid = state.collection!!
grid.index = grid.tailIndex
launch()
press(Key.DirectionCenter)
assertEquals(GridTail.None, grid.tail)
assertEquals(lane.fullTitles.map { it.key }, grid.items.map { it.id })
assertEquals(lane.fullTitles[6].key, grid.focusedTitle?.id)
press(Key.DirectionCenter)
assertEquals(lane.fullTitles[6], state.title)
}
@Test fun collectionDismissalAndUndoKeepTheOriginalOrderAndFocus() {
shell.pickerVisible = false
state.openCollection(state.lanes.first { it.id == "pk-movie" })
val grid = state.collection!!
val original = grid.items.map { it.id }
launch()
press(Key.DirectionCenter)
val opener = state.title!!
press(Key.DirectionRight, Key.DirectionRight)
press(Key.DirectionCenter)
assertFalse(grid.items.any { it.id == opener.key })
press(Key.DirectionCenter)
assertEquals(original, grid.items.map { it.id })
assertEquals(opener.key, grid.focusedTitle?.id)
}
@Test fun collectionBackRestoresTheExactOrigin() {
shell.pickerVisible = false; shell.enterDestination("discover")
state.focus(3, 2)

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import org.junit.Assert.assertEquals
import org.junit.Test
/** Feed projection must not lose titles when a contribution row is too short to display. */
class DiscoverLaneProjectionTest {
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))),
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
assertEquals(titles.take(3), rows.first().titles)
assertEquals(titles.drop(3), rows.last().titles)
}
@Test fun catalogueDeduplicationKeepsMediaTypesDistinctAndHiddenPicksLast() {
val movie = DiscoverTitle(1, "movie", "Film")
val series = DiscoverTitle(1, "tv", "Series")
val hidden = DiscoverTitle(2, "movie", "Hidden")
val later = DiscoverTitle(3, "movie", "Later")
val snapshot = DiscoverSnapshot(hidden = listOf(hidden), status = DiscoverStatus.Ready, lanes = listOf(
DiscoverLane("first", "First", listOf(movie, series, hidden)),
DiscoverLane("second", "Second", listOf(movie, series, later))))
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", mapOf(hidden.key to hidden))
assertEquals(listOf(listOf(movie, series), listOf(later), listOf(hidden)), rows.map { it.titles })
assertEquals("hidden", rows.last().id)
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
assertEquals(listOf(listOf(series)), filtered.map { it.titles })
}
}