Build the Wolfskin Home feed

This commit is contained in:
justinzeus 2026-11-05 16:15:40 +01:00
parent 9be55c570a
commit 0fd0ec035d
683 changed files with 2561 additions and 18 deletions

View file

@ -0,0 +1,26 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** An unreadable local image exercises Coil's real error path without a network provider. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class HomeArtTest {
@get:Rule val rule = createComposeRule()
@Test fun imageFailureShowsTheTitleFallbackInsteadOfAnEmptyCard() {
val title = HomeTitle("missing-image", "A missing film", "movie", poster = "file:///android_asset/wolfskin-home/not-present.jpg")
rule.setContent { WolfskinTheme { HomeArt(title, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)) } }
rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().isNotEmpty() }
rule.onNodeWithText(title.title).assertIsDisplayed()
}
}

View file

@ -0,0 +1,93 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.Locale
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Every frozen Home acceptance state renders the actual native Compose surface at density 2. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
private fun capture(name: String, person: String = "justin", mode: String = "ready", row: String = "hero", column: Int = 0, nav: Int? = null) {
val previous = Locale.getDefault()
Locale.setDefault(Locale.US)
try {
val snapshot = HomeFixture(rule.activity).snapshot(person, mode)
val shell = ShellState(fixturePeople(), person).apply {
pickerVisible = false
view.row = snapshot.rows.indexOfFirst { it.id == row }.coerceAtLeast(0)
view.cards[row] = column
if (nav != null) { navFocused = true; navIndex = nav }
}
preload(snapshot, shell.view.row)
rule.setContent { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> HomeScreen(shell, snapshot, modifier) {} }
} }
rule.mainClock.advanceTimeBy(1200)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
} finally { Locale.setDefault(previous) }
}
private fun preload(snapshot: HomeSnapshot, focused: Int) = runBlocking {
val rows = if (focused == 0) snapshot.rows.take(2) else snapshot.rows.drop(focused).take(3)
for (row in rows) for (title in row.cards.take(8)) {
val hero = row.kind == HomeRowKind.Hero
val landscape = hero || row.cardKind != HomeCardKind.Poster
val width = if (hero) 1280 else (row.cardWidth.value * 2).toInt()
val height = if (hero) 720 else (row.cardHeight.value * 2).toInt()
val source = if (landscape) title.backdrop ?: title.poster else title.poster
if (source != null) HomeImages.loader(rule.activity).execute(HomeImages.request(rule.activity, source, width, height, !landscape))
}
}
@Test fun st005_loading() = capture("st-005", mode = "loading", nav = 0)
@Test fun st006_justinNext() = capture("st-006")
@Test fun st007_mindelResume() = capture("st-007", person = "mindel")
@Test fun st008_sharedResume() = capture("st-008", person = "shared")
@Test fun st009_deepTrending() = capture("st-009", row = "trending", column = 2)
@Test fun st010_heroNav() = capture("st-010", nav = 0)
@Test fun st011_deepBackNav() = capture("st-011", row = "lib-shows", column = 2, nav = 0)
@Test fun st012_explore() = capture("st-012", row = "explore")
@Test fun st013_unsigned() = capture("st-013", mode = "unsigned", row = "signin")
@Test fun st014_discoverNav() = capture("st-014", nav = 1)
@Test fun st015_offlineSaved() = capture("st-015", mode = "offline")
@Test fun st016_conductarrDown() = capture("st-016", mode = "cond-down", row = "cond-down")
@Test fun st017_conductarrSaved() = capture("st-017", mode = "cond-cached")
@Test fun st018_jellyfinDown() = capture("st-018", mode = "jf-down")
@Test fun st019_picksUnavailable() = capture("st-019", mode = "picks-unavailable", row = "trending")
@Test fun st020_missingArt() = capture("st-020", person = "shared", mode = "missing", row = "lib-movies")
@Test fun st096_widestTilePlate() = capture("st-096", person = "mindel", row = "new", column = 4)
@Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "nu")
@Test fun st099_posterPlate() = capture("st-099", row = "lib-movies", column = 1)
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2)
@Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4)
@Test fun st103_heroRatings() = capture("st-103")
@Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
}

View file

@ -0,0 +1,116 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.*
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.json.JSONArray
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Approved replay data drives the same Android event handling used by the Home surface. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class HomeKeysTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun approvedHomePathsMatchFocusAndCounts() {
val fixture = HomeFixture(rule.activity)
var shell by mutableStateOf(ShellState(fixturePeople(), "justin"))
var action: HomeAction? = null
rule.setContent { WolfskinTheme {
val snapshot = remember(shell, shell.person) { fixture.snapshot(shell.person.key) }
ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } }
} }
val paths = javaClass.getResourceAsStream("/wolfskin-home-presses.json")!!.bufferedReader().use { JSONArray(it.readText()) }
for (index in 0 until paths.length()) {
val path = paths.getJSONObject(index)
val keys = path.getJSONArray("keys")
rule.runOnIdle { shell = ShellState(fixturePeople(), "justin"); action = null }
for (key in 0 until keys.length()) press(keys.getString(key))
assertEquals(path.getString("goal"), path.getInt("presses"), keys.length())
when (index) {
0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) }
1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) }
2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id)
3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) }
4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) }
5 -> assertEquals("discover", shell.route)
}
}
}
@Test fun cardsActivateOnlyOnReleaseAndMovementCancelsArming() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin")
var action: HomeAction? = null
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } }
press("DOWN")
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionCenter) }
assertNull(action)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyUp(Key.DirectionCenter) }
assertTrue(action is HomeAction.Details)
rule.runOnIdle { action = null }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionCenter); pressKey(Key.DirectionRight); keyUp(Key.DirectionCenter) }
assertNull(action)
}
@Test fun loadingHasNavFocus() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin", "loading")
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } }
rule.waitForIdle()
assertTrue(shell.navFocused)
assertEquals(0, shell.navIndex)
}
@Test fun returningHomeRestoresTheExactViewportAndArrowReentersVisibleContent() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin")
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
if (shell.route == "home") HomeScreen(shell, snapshot, modifier) {} else ShellPlaceholder(shell.route, modifier)
} } }
rule.onNodeWithTag("wolfskin-home").performScrollToIndex(3)
val view = shell.view
val savedIndex = view.listState.firstVisibleItemIndex
val savedOffset = view.listState.firstVisibleItemScrollOffset
assertEquals(3, savedIndex)
rule.runOnIdle { shell.enterDestination("movies") }
rule.runOnIdle { shell.enterDestination("home") }
rule.waitForIdle()
assertSame(view, shell.view)
assertEquals(savedIndex, view.listState.firstVisibleItemIndex)
assertEquals(savedOffset, view.listState.firstVisibleItemScrollOffset)
press("RIGHT")
assertEquals("lib-movies", snapshot.rows[view.row].id)
assertEquals(0, view.cards["lib-movies"])
}
@Test fun pointerHoverMovesFocusWithoutActivatingTheCard() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin")
var action: HomeAction? = null
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } }
press("DOWN")
rule.onNodeWithTag("home-card:nu:2").performMouseInput { enter(center) }
rule.waitForIdle()
assertEquals("nu", snapshot.rows[shell.view.row].id)
assertEquals(2, shell.view.cards["nu"])
assertNull(action)
}
private fun press(name: String) {
val key = when (name) {
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
"LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight
"BACK" -> Key.Back; else -> Key.DirectionCenter
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
}
}

View file

@ -0,0 +1,38 @@
package org.justinvisser.wolfskin.design
import java.util.Locale
import org.junit.Assert.*
import org.junit.Test
/** Cache boundaries protect visible ratings during offline browsing and fast repeated focus. */
class HomeRatingCacheTest {
private val title = HomeTitle("jellyfin-1", "A film", "movie", tmdbId = 42)
private val ratings = listOf(HomeRating("tmdb", 7.8))
@Test fun cacheIsSharedByCatalogueIdentityAndExpiresAtSixHours() {
var now = 0L
val cache = HomeRatingCache { now }
cache.save(title, ratings)
assertEquals(ratings, cache.read(title.copy(id = "another-server-copy")))
now = 6L * 60 * 60 * 1000 - 1
assertEquals(ratings, cache.read(title))
now++
assertNull(cache.read(title))
}
@Test fun cacheKeepsObservedAbsenceAndEvictsOldestBeyondCapacity() {
var now = 0L
val cache = HomeRatingCache { now++ }
cache.save(title, emptyList())
assertEquals(emptyList<HomeRating>(), cache.read(title))
repeat(300) { index -> cache.save(title.copy(id = "other-$index", tmdbId = index + 100), ratings) }
assertNull(cache.read(title))
assertEquals(ratings, cache.read(title.copy(id = "last", tmdbId = 399)))
}
@Test fun decimalScoresFollowLocaleButPercentagesDoNot() {
assertEquals("7,8", ratings.single().format(Locale.forLanguageTag("nl-NL")))
assertEquals("7.8", ratings.single().format(Locale.US))
assertEquals("87%", HomeRating("fresh", 87.9, percentage = true).format(Locale.forLanguageTag("nl-NL")))
}
}

View file

@ -0,0 +1,67 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.text.TextLayoutResult
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Real text layout proves locale formatting and the focus dwell instead of testing copied strings. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class HomeRatingsUiTest {
@get:Rule val rule = createComposeRule()
@Test fun dutchDecimalPlateStaysOnOneLine() {
val previous = Locale.getDefault()
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
try {
rule.setContent { WolfskinTheme { HomeRatingPlate(listOf(HomeRating("tmdb", 7.8)), true, Modifier.width(WolfskinTokens.Card.posterW)) } }
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("7,8").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) }
assertEquals(1, layouts.single().lineCount)
rule.onNodeWithText("7.8").assertDoesNotExist()
} finally { Locale.setDefault(previous) }
}
@Test fun focusDwellDefersUncachedPlateButCachedFocusReturnsImmediately() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val title = HomeTitle("dwell", "A film", "movie", ratings = listOf(HomeRating("tmdb", 7.8)))
val snapshot = HomeSnapshot(listOf(HomeRow("shelf", HomeRowKind.Shelf, "Shelf", listOf(title))), HomeAvailability.Ready)
rule.mainClock.autoAdvance = false
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } }
rule.mainClock.advanceTimeBy(100)
rule.onNodeWithText("7.8").assertDoesNotExist()
rule.mainClock.advanceTimeBy(450)
rule.onNodeWithText("7.8").assertExists()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
rule.runOnIdle { assertTrue(shell.navFocused) }
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithText("7.8").assertDoesNotExist()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) }
rule.runOnIdle { assertFalse(shell.navFocused) }
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithText("7.8").assertExists()
}
@Test fun signedOutHomeNeverDrawsHeroOrPlateScores() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val title = HomeTitle("signed-out", "A film", "movie", ratings = listOf(HomeRating("tmdb", 7.8)))
val rows = buildHomeRows(HomeLibrary(nextUp = listOf(title)), listOf(homeDiscoverState(HomeAvailability.SignedOut)))
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(rows), modifier) {} } } }
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithText("7.8").assertDoesNotExist()
}
}

View file

@ -0,0 +1,50 @@
package org.justinvisser.wolfskin.design
import org.junit.Assert.*
import org.junit.Test
/** Hero precedence and empty/error transitions preserve useful, focusable Home content. */
class HomeRowsTest {
private val resume = HomeTitle("resume", "Resume", "movie")
private val next = HomeTitle("next", "Next", "episode")
private val newest = HomeTitle("new", "New", "movie")
private val pick = HomeTitle("pick", "Pick", "movie")
private val continuation = listOf(HomeRow("pk-m", HomeRowKind.Shelf, cards = listOf(pick)))
@Test fun heroChainUsesResumeThenNextThenNewThenPickAndNeverRepeatsHeroOnShelves() {
val cases = listOf(
HomeLibrary(listOf(resume), listOf(next), listOf(newest)) to resume,
HomeLibrary(nextUp = listOf(next), newest = listOf(newest)) to next,
HomeLibrary(newest = listOf(newest)) to newest,
HomeLibrary() to pick,
)
for ((library, expected) in cases) {
val rows = buildHomeRows(library, continuation)
assertEquals(expected.id, rows.first().cards.single().id)
assertFalse(rows.filter { it.id in setOf("cw", "nu", "new") }.flatMap { it.cards }.any { it.id == expected.id })
}
}
@Test fun heldShelvesAreUnwatchedFirstAndSeeAllUsesFullDestinationCount() {
val watched = newest.copy(id = "watched", played = true, addedAt = "2026-11-05")
val older = newest.copy(id = "older", addedAt = "2026-01-01")
val recent = newest.copy(id = "recent", addedAt = "2026-10-01")
val rows = buildHomeRows(HomeLibrary(destinations = mapOf("movies" to listOf(watched, older, recent)), destinationTotals = mapOf("movies" to 84)), continuation)
val shelf = rows.single { it.id == "lib-movies" }
assertEquals(listOf("recent", "older", "watched"), shelf.cards.map { it.id })
assertEquals(84, shelf.total)
assertTrue(shelf.hasSeeAll)
}
@Test fun unavailableLibraryKeepsDiscoverAndFocusableRecoveryInsteadOfHeldShelves() {
val rows = buildHomeRows(HomeLibrary(listOf(resume), listOf(next), listOf(newest), mapOf("movies" to listOf(newest))), continuation, libraryAvailable = false)
assertEquals(listOf("hero", "jf-down", "disc-h", "pk-m"), rows.map { it.id })
assertTrue(rows.single { it.id == "jf-down" }.focusable)
}
@Test fun firstReadSkeletonHasNoFocusableContent() {
val rows = buildHomeRows(HomeLibrary(), continuation, loading = true)
assertEquals(listOf(HomeRowKind.Hero, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf), rows.map { it.kind })
assertTrue(rows.all { !it.focusable })
}
}

View file

@ -0,0 +1,126 @@
[
{
"goal": "Resume (Mindel, from launch)",
"keys": [
"RIGHT",
"OK",
"OK"
],
"presses": 3,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Play next (Justin has no resume)",
"keys": [
"OK",
"OK"
],
"presses": 2,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 3 \u00b7 Home hero \u2192 Trending (D2 split)",
"keys": [
"OK",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN"
],
"presses": 13,
"note": "12 Downs after the launch OK",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 3 \u00b7 Trending \u2192 its See all card (D3: 20 cards)",
"keys": [
"OK",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT"
],
"presses": 33,
"note": "20 Rights from Trending",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 3 \u00b7 deep Home row: Back to the nav, Down to the same card",
"keys": [
"OK",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"RIGHT",
"RIGHT",
"BACK",
"DOWN"
],
"presses": 9,
"note": "2: Back, Down",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Open Discover",
"keys": [
"OK",
"UP",
"RIGHT",
"OK"
],
"presses": 4,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
}
]