diff --git a/app/build.gradle b/app/build.gradle
index e0725339f..5559d0b2b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -149,6 +149,8 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:1.13.7"
+ testImplementation "org.robolectric:robolectric:4.9"
+ testImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
diff --git a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt
deleted file mode 100644
index a0ba54a3d..000000000
--- a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright (c) 2023 Auxio Project
- * StubTest.kt is part of Auxio.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.oxycblt.auxio
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class StubTest {
- // TODO: Make tests
- @Test
- fun useAppContext() {
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("org.oxycblt.auxio.debug", appContext.packageName)
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
index d28547239..2e3e8a944 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
@@ -63,9 +63,9 @@ data class CachedSong(
/** @see RawSong */
var durationMs: Long,
/** @see RawSong.replayGainTrackAdjustment */
- val replayGainTrackAdjustment: Float?,
+ val replayGainTrackAdjustment: Float? = null,
/** @see RawSong.replayGainAlbumAdjustment */
- val replayGainAlbumAdjustment: Float?,
+ val replayGainAlbumAdjustment: Float? = null,
/** @see RawSong.musicBrainzId */
var musicBrainzId: String? = null,
/** @see RawSong.name */
diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt
index 989a7b128..8d2740e74 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt
@@ -43,6 +43,7 @@ interface Separators {
const val SLASH = '/'
const val PLUS = '+'
const val AND = '&'
+
/**
* Creates a new instance from the **current state** of the given [MusicSettings]'s
* user-defined separator configuration.
diff --git a/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt
new file mode 100644
index 000000000..9914dbe5f
--- /dev/null
+++ b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt
@@ -0,0 +1,266 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ * CacheRepositoryTest.kt is part of Auxio.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music.cache
+
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerifyAll
+import io.mockk.coVerifySequence
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import java.lang.IllegalStateException
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.oxycblt.auxio.music.device.RawSong
+import org.oxycblt.auxio.music.info.Date
+
+class CacheRepositoryTest {
+ @Test
+ fun cache_read_noInvalidate() {
+ val dao =
+ mockk {
+ coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B))
+ }
+ val cacheRepository = CacheRepositoryImpl(dao)
+ val cache = requireNotNull(runBlocking { cacheRepository.readCache() })
+ coVerifyAll { dao.readSongs() }
+ assertFalse(cache.invalidated)
+
+ val songA = RawSong(mediaStoreId = 0, dateAdded = 1, dateModified = 2)
+ assertTrue(cache.populate(songA))
+ assertEquals(RAW_SONG_A, songA)
+
+ assertFalse(cache.invalidated)
+
+ val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11)
+ assertTrue(cache.populate(songB))
+ assertEquals(RAW_SONG_B, songB)
+
+ assertFalse(cache.invalidated)
+ }
+
+ @Test
+ fun cache_read_invalidate() {
+ val dao =
+ mockk {
+ coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B))
+ }
+ val cacheRepository = CacheRepositoryImpl(dao)
+ val cache = requireNotNull(runBlocking { cacheRepository.readCache() })
+ coVerifyAll { dao.readSongs() }
+ assertFalse(cache.invalidated)
+
+ val nullStart = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0)
+ val nullEnd = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0)
+ assertFalse(cache.populate(nullStart))
+ assertEquals(nullStart, nullEnd)
+
+ assertTrue(cache.invalidated)
+
+ val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11)
+ assertTrue(cache.populate(songB))
+ assertEquals(RAW_SONG_B, songB)
+
+ assertTrue(cache.invalidated)
+ }
+
+ @Test
+ fun cache_read_crashes() {
+ val dao = mockk { coEvery { readSongs() } throws IllegalStateException() }
+ val cacheRepository = CacheRepositoryImpl(dao)
+ assertEquals(null, runBlocking { cacheRepository.readCache() })
+ coVerifyAll { dao.readSongs() }
+ }
+
+ @Test
+ fun cache_write() {
+ var currentlyStoredSongs = listOf()
+ val insertSongsArg = slot>()
+ val dao =
+ mockk {
+ coEvery { nukeSongs() } answers { currentlyStoredSongs = listOf() }
+
+ coEvery { insertSongs(capture(insertSongsArg)) } answers
+ {
+ currentlyStoredSongs = insertSongsArg.captured
+ }
+ }
+
+ val cacheRepository = CacheRepositoryImpl(dao)
+
+ val rawSongs = listOf(RAW_SONG_A, RAW_SONG_B)
+ runBlocking { cacheRepository.writeCache(rawSongs) }
+
+ val cachedSongs = listOf(CACHED_SONG_A, CACHED_SONG_B)
+ coVerifySequence {
+ dao.nukeSongs()
+ dao.insertSongs(cachedSongs)
+ }
+ assertEquals(cachedSongs, currentlyStoredSongs)
+ }
+
+ @Test
+ fun cache_write_nukeCrashes() {
+ val dao =
+ mockk {
+ coEvery { nukeSongs() } throws IllegalStateException()
+ coEvery { insertSongs(listOf()) } just Runs
+ }
+ val cacheRepository = CacheRepositoryImpl(dao)
+ runBlocking { cacheRepository.writeCache(listOf()) }
+ coVerifyAll { dao.nukeSongs() }
+ }
+
+ @Test
+ fun cache_write_insertCrashes() {
+ val dao =
+ mockk {
+ coEvery { nukeSongs() } just Runs
+ coEvery { insertSongs(listOf()) } throws IllegalStateException()
+ }
+ val cacheRepository = CacheRepositoryImpl(dao)
+ runBlocking { cacheRepository.writeCache(listOf()) }
+ coVerifySequence {
+ dao.nukeSongs()
+ dao.insertSongs(listOf())
+ }
+ }
+
+ private companion object {
+ val CACHED_SONG_A =
+ CachedSong(
+ mediaStoreId = 0,
+ dateAdded = 1,
+ dateModified = 2,
+ size = 3,
+ durationMs = 4,
+ replayGainTrackAdjustment = 5.5f,
+ replayGainAlbumAdjustment = 6.6f,
+ musicBrainzId = "Song MBID A",
+ name = "Song Name A",
+ sortName = "Song Sort Name A",
+ track = 7,
+ disc = 8,
+ subtitle = "Subtitle A",
+ date = Date.from("2020-10-10"),
+ albumMusicBrainzId = "Album MBID A",
+ albumName = "Album Name A",
+ albumSortName = "Album Sort Name A",
+ releaseTypes = listOf("Release Type A"),
+ artistMusicBrainzIds = listOf("Artist MBID A"),
+ artistNames = listOf("Artist Name A"),
+ artistSortNames = listOf("Artist Sort Name A"),
+ albumArtistMusicBrainzIds = listOf("Album Artist MBID A"),
+ albumArtistNames = listOf("Album Artist Name A"),
+ albumArtistSortNames = listOf("Album Artist Sort Name A"),
+ genreNames = listOf("Genre Name A"),
+ )
+
+ val RAW_SONG_A =
+ RawSong(
+ mediaStoreId = 0,
+ dateAdded = 1,
+ dateModified = 2,
+ size = 3,
+ durationMs = 4,
+ replayGainTrackAdjustment = 5.5f,
+ replayGainAlbumAdjustment = 6.6f,
+ musicBrainzId = "Song MBID A",
+ name = "Song Name A",
+ sortName = "Song Sort Name A",
+ track = 7,
+ disc = 8,
+ subtitle = "Subtitle A",
+ date = Date.from("2020-10-10"),
+ albumMusicBrainzId = "Album MBID A",
+ albumName = "Album Name A",
+ albumSortName = "Album Sort Name A",
+ releaseTypes = listOf("Release Type A"),
+ artistMusicBrainzIds = listOf("Artist MBID A"),
+ artistNames = listOf("Artist Name A"),
+ artistSortNames = listOf("Artist Sort Name A"),
+ albumArtistMusicBrainzIds = listOf("Album Artist MBID A"),
+ albumArtistNames = listOf("Album Artist Name A"),
+ albumArtistSortNames = listOf("Album Artist Sort Name A"),
+ genreNames = listOf("Genre Name A"),
+ )
+
+ val CACHED_SONG_B =
+ CachedSong(
+ mediaStoreId = 9,
+ dateAdded = 10,
+ dateModified = 11,
+ size = 12,
+ durationMs = 13,
+ replayGainTrackAdjustment = 14.14f,
+ replayGainAlbumAdjustment = 15.15f,
+ musicBrainzId = "Song MBID B",
+ name = "Song Name B",
+ sortName = "Song Sort Name B",
+ track = 16,
+ disc = 17,
+ subtitle = "Subtitle B",
+ date = Date.from("2021-11-11"),
+ albumMusicBrainzId = "Album MBID B",
+ albumName = "Album Name B",
+ albumSortName = "Album Sort Name B",
+ releaseTypes = listOf("Release Type B"),
+ artistMusicBrainzIds = listOf("Artist MBID B"),
+ artistNames = listOf("Artist Name B"),
+ artistSortNames = listOf("Artist Sort Name B"),
+ albumArtistMusicBrainzIds = listOf("Album Artist MBID B"),
+ albumArtistNames = listOf("Album Artist Name B"),
+ albumArtistSortNames = listOf("Album Artist Sort Name B"),
+ genreNames = listOf("Genre Name B"),
+ )
+
+ val RAW_SONG_B =
+ RawSong(
+ mediaStoreId = 9,
+ dateAdded = 10,
+ dateModified = 11,
+ size = 12,
+ durationMs = 13,
+ replayGainTrackAdjustment = 14.14f,
+ replayGainAlbumAdjustment = 15.15f,
+ musicBrainzId = "Song MBID B",
+ name = "Song Name B",
+ sortName = "Song Sort Name B",
+ track = 16,
+ disc = 17,
+ subtitle = "Subtitle B",
+ date = Date.from("2021-11-11"),
+ albumMusicBrainzId = "Album MBID B",
+ albumName = "Album Name B",
+ albumSortName = "Album Sort Name B",
+ releaseTypes = listOf("Release Type B"),
+ artistMusicBrainzIds = listOf("Artist MBID B"),
+ artistNames = listOf("Artist Name B"),
+ artistSortNames = listOf("Artist Sort Name B"),
+ albumArtistMusicBrainzIds = listOf("Album Artist MBID B"),
+ albumArtistNames = listOf("Album Artist Name B"),
+ albumArtistSortNames = listOf("Album Artist Sort Name B"),
+ genreNames = listOf("Genre Name B"),
+ )
+ }
+}