mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-06 03:21:22 +00:00
test(simkl): cover local scrobble reconciliation
This commit is contained in:
parent
2ebaf3b5a9
commit
247afcbf00
4 changed files with 297 additions and 8 deletions
|
|
@ -227,8 +227,8 @@ class SimklAnimeWatchedResolutionTest {
|
|||
val resolved = reference.resolveAnimeEpisodeForSimkl()
|
||||
|
||||
assertEquals(42203L, resolved.ids.mal)
|
||||
assertEquals("tt2560140", resolved.ids.imdb)
|
||||
assertEquals(39687L, resolved.ids.simkl)
|
||||
assertNull(resolved.ids.imdb)
|
||||
assertNull(resolved.ids.simkl)
|
||||
assertNull(resolved.episode?.season)
|
||||
assertEquals(7, resolved.episode?.number)
|
||||
}
|
||||
|
|
@ -250,7 +250,7 @@ class SimklAnimeWatchedResolutionTest {
|
|||
val resolved = reference.resolveAnimeEpisodeForSimkl()
|
||||
|
||||
assertEquals(12268L, resolved.ids.kitsu)
|
||||
assertEquals("tt5311514", resolved.ids.imdb)
|
||||
assertNull(resolved.ids.imdb)
|
||||
assertNull(resolved.episode?.season)
|
||||
assertEquals(3, resolved.episode?.number)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,13 +153,16 @@ class SimklMutationRepositoryTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `service reports partial not found and treats duplicate stop as committed`() = runBlocking {
|
||||
fun `service reports partial not found and returns duplicate stop as scrobbled`() = runBlocking {
|
||||
val engine = RecordingEngine(
|
||||
response(
|
||||
status = 201,
|
||||
body = """{"added":{"movies":[{"to":"completed"}]},"not_found":{"movies":[{"title":"Missing"}],"shows":[]}}""",
|
||||
),
|
||||
response(status = 409),
|
||||
response(
|
||||
status = 409,
|
||||
body = """{"watched_at":"2026-05-14T23:46:29Z","expires_at":"2026-05-15T00:46:29Z"}""",
|
||||
),
|
||||
)
|
||||
var now = 0L
|
||||
var committed = 0
|
||||
|
|
@ -179,7 +182,7 @@ class SimklMutationRepositoryTest {
|
|||
items = listOf(movie(), movie().copy(title = "Missing", ids = TrackingExternalIds())),
|
||||
destination = TrackingListStatus.PLAN_TO_WATCH,
|
||||
)
|
||||
service.scrobble(
|
||||
val scrobbleResult = service.scrobble(
|
||||
action = TrackingScrobbleAction.STOP,
|
||||
event = TrackingScrobbleEvent(movie(), progressPercent = 90.0),
|
||||
)
|
||||
|
|
@ -189,7 +192,9 @@ class SimklMutationRepositoryTest {
|
|||
assertEquals(listOf(TrackingListStatus.COMPLETED), result.resolvedListStatuses)
|
||||
assertFalse(result.isComplete)
|
||||
assertEquals(listOf("/sync/add-to-list", "/scrobble/stop"), engine.paths)
|
||||
assertEquals(2, committed)
|
||||
assertEquals(SimklScrobbleOutcome.SCROBBLE, scrobbleResult.outcome)
|
||||
assertEquals("2026-05-14T23:46:29Z", scrobbleResult.watchedAt)
|
||||
assertEquals(1, committed)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -268,6 +273,72 @@ class SimklMutationRepositoryTest {
|
|||
assertEquals(listOf("/scrobble/pause"), engine.paths)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful pause returns local reconciliation data without invalidating sync`() = runBlocking {
|
||||
val engine = RecordingEngine(
|
||||
response(
|
||||
status = 201,
|
||||
body = """{"id":42,"action":"pause","progress":45,"movie":{"title":"Terminator 3: Rise of the Machines","year":2003,"ids":{"simkl":53536}}}""",
|
||||
),
|
||||
)
|
||||
var committed = 0
|
||||
val service = SimklMutationService(
|
||||
client = SimklApiClient(
|
||||
engine = engine,
|
||||
accessToken = { "token" },
|
||||
onUnauthorized = {},
|
||||
nowEpochMs = { 0L },
|
||||
sleep = {},
|
||||
retryJitterMs = { 0L },
|
||||
),
|
||||
onMutationCommitted = { committed += 1 },
|
||||
)
|
||||
|
||||
val result = service.scrobble(
|
||||
action = TrackingScrobbleAction.PAUSE,
|
||||
event = TrackingScrobbleEvent(movie(), progressPercent = 45.0),
|
||||
)
|
||||
|
||||
assertEquals(listOf("/scrobble/pause"), engine.paths)
|
||||
assertEquals(SimklScrobbleOutcome.PAUSE, result.outcome)
|
||||
assertEquals(42L, result.playbackId)
|
||||
assertEquals(45.0, result.progress)
|
||||
assertEquals(0, committed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low progress stop returns a paused playback without invalidating sync`() = runBlocking {
|
||||
val engine = RecordingEngine(
|
||||
response(
|
||||
status = 201,
|
||||
body = """{"id":42,"action":"pause","progress":30,"movie":{"title":"Terminator 3: Rise of the Machines","year":2003,"ids":{"simkl":53536}}}""",
|
||||
),
|
||||
)
|
||||
var committed = 0
|
||||
val service = SimklMutationService(
|
||||
client = SimklApiClient(
|
||||
engine = engine,
|
||||
accessToken = { "token" },
|
||||
onUnauthorized = {},
|
||||
nowEpochMs = { 0L },
|
||||
sleep = {},
|
||||
retryJitterMs = { 0L },
|
||||
),
|
||||
onMutationCommitted = { committed += 1 },
|
||||
)
|
||||
|
||||
val result = service.scrobble(
|
||||
action = TrackingScrobbleAction.STOP,
|
||||
event = TrackingScrobbleEvent(movie(), progressPercent = 30.0),
|
||||
)
|
||||
|
||||
assertEquals(listOf("/scrobble/stop"), engine.paths)
|
||||
assertEquals(SimklScrobbleOutcome.PAUSE, result.outcome)
|
||||
assertEquals(42L, result.playbackId)
|
||||
assertEquals(30.0, result.progress)
|
||||
assertEquals(0, committed)
|
||||
}
|
||||
|
||||
private fun String.asObject() = json.parseToJsonElement(this).jsonObject
|
||||
|
||||
private fun movie() = TrackingMediaReference(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
package com.nuvio.app.features.simkl
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SimklScrobbleReconciliationTest {
|
||||
@Test
|
||||
fun `pause replaces local playback without advancing the sync watermark`() {
|
||||
val media = showMedia(simklId = 2090L, imdb = "tt1520211")
|
||||
val snapshot = SimklSyncSnapshot(
|
||||
isInitialized = true,
|
||||
watermark = "v1",
|
||||
entries = listOf(
|
||||
SimklLibraryEntry(
|
||||
mediaType = SimklMediaType.SHOWS,
|
||||
status = SimklListStatus.WATCHING,
|
||||
show = media,
|
||||
),
|
||||
),
|
||||
playback = listOf(
|
||||
SimklPlaybackSession(
|
||||
id = 4L,
|
||||
progress = 20.0,
|
||||
pausedAt = "2023-11-14T21:00:00Z",
|
||||
type = "episode",
|
||||
episode = SimklPlaybackEpisode(season = 1, number = 2),
|
||||
show = media,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val updated = snapshot.applyScrobbleResult(
|
||||
result = SimklScrobbleResult(
|
||||
outcome = SimklScrobbleOutcome.PAUSE,
|
||||
playbackId = 8L,
|
||||
progress = 45.0,
|
||||
mediaType = SimklMediaType.SHOWS,
|
||||
media = media,
|
||||
episode = SimklPlaybackEpisode(season = 1, number = 2),
|
||||
),
|
||||
committedAtEpochMs = 1_700_000_000_000L,
|
||||
)
|
||||
|
||||
assertEquals("v1", updated.watermark)
|
||||
assertEquals(1, updated.playback.size)
|
||||
assertEquals(8L, updated.playback.single().id)
|
||||
assertEquals(45.0, updated.playback.single().progress)
|
||||
assertEquals("2023-11-14T22:13:20Z", updated.playback.single().pausedAt)
|
||||
assertTrue(updated.toSimklWatchedProjection().items.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed stop records the exact episode and removes its playback`() {
|
||||
val media = showMedia(simklId = 2090L, imdb = "tt1520211")
|
||||
val snapshot = SimklSyncSnapshot(
|
||||
isInitialized = true,
|
||||
watermark = "v1",
|
||||
entries = listOf(
|
||||
SimklLibraryEntry(
|
||||
mediaType = SimklMediaType.SHOWS,
|
||||
status = SimklListStatus.WATCHING,
|
||||
show = media,
|
||||
seasons = listOf(
|
||||
SimklSeason(
|
||||
number = 1,
|
||||
episodes = listOf(SimklEpisode(number = 2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
playback = listOf(
|
||||
SimklPlaybackSession(
|
||||
id = 8L,
|
||||
progress = 70.0,
|
||||
pausedAt = "2023-11-14T21:00:00Z",
|
||||
type = "episode",
|
||||
episode = SimklPlaybackEpisode(season = 1, number = 2),
|
||||
show = media,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val updated = snapshot.applyScrobbleResult(
|
||||
result = SimklScrobbleResult(
|
||||
outcome = SimklScrobbleOutcome.SCROBBLE,
|
||||
playbackId = 9L,
|
||||
progress = 90.0,
|
||||
mediaType = SimklMediaType.SHOWS,
|
||||
media = media,
|
||||
episode = SimklPlaybackEpisode(season = 1, number = 2),
|
||||
),
|
||||
committedAtEpochMs = 1_700_000_000_000L,
|
||||
)
|
||||
|
||||
assertEquals("v1", updated.watermark)
|
||||
assertTrue(updated.playback.isEmpty())
|
||||
assertEquals(1, updated.entries.single().watchedEpisodesCount)
|
||||
val watched = updated.toSimklWatchedProjection().items.single()
|
||||
assertEquals(1, watched.season)
|
||||
assertEquals(2, watched.episode)
|
||||
assertEquals(1_700_000_000_000L, watched.markedAtEpochMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anime stop updates only the matching Simkl title when siblings share IMDb`() {
|
||||
val firstMedia = animeMedia(100L, "tt2560140", 16498L)
|
||||
val secondMedia = animeMedia(101L, "tt2560140", 25777L)
|
||||
val snapshot = SimklSyncSnapshot(
|
||||
entries = listOf(
|
||||
animeEntry(firstMedia, season = 1, episode = 3),
|
||||
animeEntry(secondMedia, season = 1, episode = 3, tvdbSeason = 2),
|
||||
),
|
||||
)
|
||||
|
||||
val updated = snapshot.applyScrobbleResult(
|
||||
result = SimklScrobbleResult(
|
||||
outcome = SimklScrobbleOutcome.SCROBBLE,
|
||||
playbackId = 12L,
|
||||
progress = 95.0,
|
||||
mediaType = SimklMediaType.ANIME,
|
||||
media = secondMedia,
|
||||
episode = SimklPlaybackEpisode(
|
||||
season = 1,
|
||||
number = 3,
|
||||
tvdbSeason = 2,
|
||||
tvdbNumber = 3,
|
||||
),
|
||||
),
|
||||
committedAtEpochMs = 1_700_000_000_000L,
|
||||
)
|
||||
|
||||
val first = updated.entries.first { it.media?.ids?.simklIdValue() == "100" }
|
||||
val second = updated.entries.first { it.media?.ids?.simklIdValue() == "101" }
|
||||
assertNull(first.seasons.single().episodes.single().watchedAt)
|
||||
assertEquals(
|
||||
"2023-11-14T22:13:20Z",
|
||||
second.seasons.single().episodes.single().watchedAt,
|
||||
)
|
||||
val watched = updated.toSimklWatchedProjection().items.single()
|
||||
assertEquals(2, watched.season)
|
||||
assertEquals(3, watched.episode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed movie stop creates watched state without an activities refresh`() {
|
||||
val media = SimklMedia(
|
||||
title = "Inception",
|
||||
year = 2010,
|
||||
ids = mapOf(
|
||||
"simkl" to JsonPrimitive(472214L),
|
||||
"imdb" to JsonPrimitive("tt1375666"),
|
||||
),
|
||||
)
|
||||
|
||||
val updated = SimklSyncSnapshot().applyScrobbleResult(
|
||||
result = SimklScrobbleResult(
|
||||
outcome = SimklScrobbleOutcome.SCROBBLE,
|
||||
playbackId = 15L,
|
||||
progress = 95.0,
|
||||
mediaType = SimklMediaType.MOVIES,
|
||||
media = media,
|
||||
episode = null,
|
||||
watchedAt = "2023-11-14T21:00:00Z",
|
||||
),
|
||||
committedAtEpochMs = 1_700_000_000_000L,
|
||||
)
|
||||
|
||||
assertEquals(SimklListStatus.COMPLETED, updated.entries.single().status)
|
||||
assertEquals("2023-11-14T21:00:00Z", updated.entries.single().lastWatchedAt)
|
||||
assertEquals("tt1375666", updated.toSimklWatchedProjection().items.single().id)
|
||||
}
|
||||
|
||||
private fun showMedia(simklId: Long, imdb: String) = SimklMedia(
|
||||
title = "The Walking Dead",
|
||||
year = 2010,
|
||||
ids = mapOf(
|
||||
"simkl" to JsonPrimitive(simklId),
|
||||
"imdb" to JsonPrimitive(imdb),
|
||||
),
|
||||
)
|
||||
|
||||
private fun animeMedia(simklId: Long, imdb: String, mal: Long) = SimklMedia(
|
||||
title = "Attack on Titan",
|
||||
year = 2013,
|
||||
ids = mapOf(
|
||||
"simkl" to JsonPrimitive(simklId),
|
||||
"imdb" to JsonPrimitive(imdb),
|
||||
"mal" to JsonPrimitive(mal),
|
||||
),
|
||||
)
|
||||
|
||||
private fun animeEntry(
|
||||
media: SimklMedia,
|
||||
season: Int,
|
||||
episode: Int,
|
||||
tvdbSeason: Int? = null,
|
||||
) = SimklLibraryEntry(
|
||||
mediaType = SimklMediaType.ANIME,
|
||||
status = SimklListStatus.WATCHING,
|
||||
show = media,
|
||||
seasons = listOf(
|
||||
SimklSeason(
|
||||
number = season,
|
||||
episodes = listOf(
|
||||
SimklEpisode(
|
||||
number = episode,
|
||||
tvdb = tvdbSeason?.let { value ->
|
||||
SimklEpisodeMapping(value, episode)
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -359,7 +359,7 @@ class SimklSyncEngineTest {
|
|||
assertTrue("extended=full" in urls[0])
|
||||
assertTrue("episode_watched_at=yes" in urls[0])
|
||||
assertTrue("include_all_episodes=yes" in urls[0])
|
||||
assertFalse("episode_tvdb_id=" in urls[0])
|
||||
assertTrue("episode_tvdb_id=yes" in urls[0])
|
||||
assertTrue("date_from=2026-05-08T14%3A23%3A11Z" in urls[1])
|
||||
assertTrue("extended=full_anime_seasons" in urls[1])
|
||||
assertTrue("episode_watched_at=yes" in urls[1])
|
||||
|
|
|
|||
Loading…
Reference in a new issue