diff options
Diffstat (limited to 'app/src')
4 files changed, 226 insertions, 5 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt new file mode 100644 index 0000000..a181dd7 --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt @@ -0,0 +1,131 @@ +package org.terst.listen.data + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The order shows appear in on the library screen. + * + * Instrumented because the ordering is SQL: the fake DAOs return insertion order, so this is + * exactly the kind of rule that cannot be pinned off-device. + * + * A show ranks by its newest episode that is still waiting for you -- unplayed *and* unqueued. + * Peter's words: once they're on the queue they're noise in the list. + */ +@RunWith(AndroidJUnit4::class) +class LibraryOrderTest { + + private lateinit var db: AppDatabase + private lateinit var subscriptions: SubscriptionDao + private lateinit var episodes: EpisodeDao + private lateinit var queue: QueueDao + + private val day = 24 * 60 * 60 * 1000L + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).build() + subscriptions = db.subscriptions() + episodes = db.episodes() + queue = db.queue() + } + + @After + fun tearDown() = db.close() + + private suspend fun show(id: String) = subscriptions.insert( + SubscriptionEntity(id = id, feedUrl = "https://example.com/$id", title = id, addedAtMs = 0) + ) + + private suspend fun episode(id: String, showId: String, publishedAtMs: Long) = + episodes.insertAll( + listOf( + EpisodeEntity( + id = id, + subscriptionId = showId, + guid = id, + title = id, + mediaUrl = "https://cdn/$id.mp3", + mediaType = "audio/mpeg", + publishedAtMs = publishedAtMs, + discoveredAtMs = 0, + ) + ) + ) + + private suspend fun order() = subscriptions.observeAll().first().map { it.id } + + @Test + fun ranksByTheNewestEpisodeStillWaiting() = runBlocking { + show("older") + show("newer") + episode("e1", "older", 1 * day) + episode("e2", "newer", 5 * day) + + assertEquals(listOf("newer", "older"), order()) + } + + /** + * The change Peter asked for. Queueing a show's newest episode deals with it, so the show + * should stop being pulled to the top by something already waiting in Up Next. + */ + @Test + fun aQueuedEpisodeStopsPullingItsShowUp() = runBlocking { + show("older") + show("newer") + episode("e1", "older", 1 * day) + episode("e2", "newer", 5 * day) + assertEquals(listOf("newer", "older"), order()) + + queue.replaceAll(listOf("e2")) + + assertEquals("the queued show has nothing left waiting", listOf("older", "newer"), order()) + } + + /** An older unqueued episode still counts -- the show is ranked by what remains. */ + @Test + fun anOlderUnqueuedEpisodeStillCounts() = runBlocking { + show("a") + show("b") + episode("a-new", "a", 9 * day) + episode("a-old", "a", 2 * day) + episode("b-mid", "b", 4 * day) + + queue.replaceAll(listOf("a-new")) + + assertEquals("a still has a 2-day-old episode, b has a 4-day-old one", listOf("b", "a"), order()) + } + + /** Played episodes were already excluded; queued ones join them. */ + @Test + fun playedEpisodesStillDoNotCount() = runBlocking { + show("a") + show("b") + episode("a-new", "a", 9 * day) + episode("b-mid", "b", 4 * day) + episodes.markPlayed("a-new", 1) + + assertEquals(listOf("b", "a"), order()) + } + + /** A show with nothing waiting sorts last, by title, rather than vanishing. */ + @Test + fun aShowWithNothingWaitingSortsLast() = runBlocking { + show("empty") + show("active") + episode("e1", "active", 3 * day) + + assertEquals(listOf("active", "empty"), order()) + } +} diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt index 0eeab50..181e853 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -148,4 +148,53 @@ class MigrationTest { assertEquals(true, cursor.isNull(1)) } } + + /** + * The trailer and preview filters arrive as ordinary rules, after anything already there. + * + * Taking position 0 would let them pre-empt rules already being relied on, which is not a + * thing an upgrade should do quietly. + */ + @Test + fun migrates4To5SeedingFiltersAfterExistingRules() { + helper.createDatabase(name, 4).use { db -> + db.execSQL( + """INSERT INTO rules (id, name, position, enabled, action, autoDownload) + VALUES ('mine', 'My rule', 0, 1, 'QUEUE_BOTTOM', 0)""" + ) + } + + val db = helper.runMigrationsAndValidate(name, 5, true, AppDatabase.MIGRATION_4_5) + + db.query("SELECT id, titleContains, action, enabled FROM rules ORDER BY position").use { cursor -> + assertEquals(3, cursor.count) + cursor.moveToFirst() + assertEquals("the listener's own rule keeps its place at the top", "mine", cursor.getString(0)) + cursor.moveToNext() + assertEquals("listen-default-trailer", cursor.getString(0)) + assertEquals("trailer", cursor.getString(1)) + assertEquals("MARK_PLAYED", cursor.getString(2)) + assertEquals(1, cursor.getInt(3)) + cursor.moveToNext() + assertEquals("listen-default-preview", cursor.getString(0)) + assertEquals("preview", cursor.getString(1)) + } + } + + /** A fresh install migrating the whole way still ends up with exactly the two filters. */ + @Test + fun migrates1To5SeedingExactlyTwoFilters() { + helper.createDatabase(name, 1).close() + + val db = helper.runMigrationsAndValidate( + name, 5, true, + AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, + AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, + ) + + db.query("SELECT COUNT(*) FROM rules").use { cursor -> + cursor.moveToFirst() + assertEquals(2, cursor.getInt(0)) + } + } } diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt index 7897678..c33921b 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -25,7 +25,7 @@ class Converters { QueueEntity::class, RuleEntity::class, ], - version = 4, + version = 5, exportSchema = true, ) @TypeConverters(Converters::class) @@ -98,6 +98,42 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * Seeds the trailer and preview filters Peter asked for. + * + * Rules live in the database on the phone, so a migration is the only way to put them + * there. They are ordinary rules: visible in the Rules screen, and editable, disableable + * or deletable like any other. Appended after whatever is already there rather than + * taking position 0, so they cannot pre-empt rules already being relied on, and they + * only act on newly discovered episodes -- nothing already in the library changes. + * + * INSERT OR IGNORE on a fixed id, so deleting one does not bring it back on the next + * upgrade. + */ + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + seedFilter(db, "listen-default-trailer", "Trailers", "trailer") + seedFilter(db, "listen-default-preview", "Previews", "preview") + } + + private fun seedFilter( + db: SupportSQLiteDatabase, + id: String, + name: String, + word: String, + ) { + db.execSQL( + "INSERT OR IGNORE INTO rules " + + "(id, name, position, enabled, subscriptionId, titleContains, " + + "titleExcludes, minDurationMs, maxDurationMs, maxAgeMs, seasonNumber, " + + "action, autoDownload, speed) " + + "SELECT ?, ?, COALESCE(MAX(position), -1) + 1, 1, NULL, ?, NULL, " + + "NULL, NULL, NULL, NULL, 'MARK_PLAYED', 0, NULL FROM rules", + arrayOf(id, name, word), + ) + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -106,7 +142,7 @@ abstract class AppDatabase : RoomDatabase() { Room.databaseBuilder(context, AppDatabase::class.java, "listen.db") // Listed one by one rather than spread from an array: every migration here is a // deliberate addition, and the list is the record of them. - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) // No fallbackToDestructiveMigration. A listener's playback positions and play // history are not reproducible from anywhere, so a missing migration must fail // loudly rather than silently wipe the library. diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt index 608cf77..abc7ea3 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -11,17 +11,22 @@ import kotlinx.coroutines.flow.Flow @Dao interface SubscriptionDao { /** - * Newest *unplayed* episode first. + * Newest episode *still waiting* first -- unplayed, and not already queued. * * Not newest episode: a show you are caught up on would sit at the top having nothing for * you, which is the opposite of useful. Sorting on what is still unheard puts whatever * actually wants listening to at the top, and drops a finished show down the list until it - * publishes again. Shows with nothing unplayed sort last, which is the COALESCE to 0. + * publishes again. Shows with nothing waiting sort last, which is the COALESCE to 0. + * + * Queued episodes are excluded for the same reason played ones are: they have been dealt + * with. Peter's words -- once they're on the queue they're noise in the list. */ @Query( """SELECT s.* FROM subscriptions s LEFT JOIN (SELECT subscriptionId, MAX(COALESCE(publishedAtMs, 0)) AS latest - FROM episodes WHERE playedAtMs IS NULL GROUP BY subscriptionId) e + FROM episodes + WHERE playedAtMs IS NULL AND id NOT IN (SELECT episodeId FROM queue) + GROUP BY subscriptionId) e ON e.subscriptionId = s.id ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE""" ) |
