Merge pull request #244 from ouchadam/tech/gallery-tests

Tech/Image Gallery Tests
This commit is contained in:
Adam Brown 2022-11-03 12:44:51 +00:00 committed by GitHub
commit 78a4cbd140
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 470 additions and 64 deletions

View File

@ -0,0 +1,30 @@
package app.dapk.st.core
import android.content.ContentResolver
import android.database.Cursor
import android.net.Uri
data class ContentResolverQuery(
val uri: Uri,
val projection: List<String>,
val selection: String,
val selectionArgs: List<String>,
val sortBy: String,
)
inline fun <T> ContentResolver.reduce(query: ContentResolverQuery, operation: (Cursor) -> T): List<T> {
return this.reduce(query, mutableListOf<T>()) { acc, cursor ->
acc.add(operation(cursor))
acc
}
}
inline fun <T> ContentResolver.reduce(query: ContentResolverQuery, initial: T, operation: (T, Cursor) -> T): T {
var accumulator: T = initial
this.query(query.uri, query.projection.toTypedArray(), query.selection, query.selectionArgs.toTypedArray(), query.sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
accumulator = operation(accumulator, cursor)
}
}
return accumulator
}

View File

@ -13,4 +13,20 @@ class FakeContentResolver {
fun givenFile(uri: Uri) = every { instance.openInputStream(uri) }.delegateReturn() fun givenFile(uri: Uri) = every { instance.openInputStream(uri) }.delegateReturn()
fun givenUriResult(uri: Uri) = every { instance.query(uri, null, null, null, null) }.delegateReturn() fun givenUriResult(uri: Uri) = every { instance.query(uri, null, null, null, null) }.delegateReturn()
fun givenQueryResult(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?,
) = every {
instance.query(
uri,
projection,
selection,
selectionArgs,
sortOrder
)
}.delegateReturn()
} }

View File

@ -24,4 +24,56 @@ class FakeCursor {
every { instance.getColumnIndex(columnName) } returns columnId every { instance.getColumnIndex(columnName) } returns columnId
every { instance.getString(columnId) } returns content every { instance.getString(columnId) } returns content
} }
}
interface CreateCursorScope {
fun addRow(vararg item: Pair<String, Any?>)
}
fun createCursor(creator: CreateCursorScope.() -> Unit): Cursor {
val content = mutableListOf<Map<String, Any?>>()
val scope = object : CreateCursorScope {
override fun addRow(vararg item: Pair<String, Any?>) {
content.add(item.toMap())
}
}
creator(scope)
return StubCursor(content)
}
private class StubCursor(private val content: List<Map<String, Any?>>) : Cursor by mockk() {
private val columnNames = content.map { it.keys }.flatten().distinct()
private var currentRowIndex = -1
override fun getColumnIndexOrThrow(columnName: String): Int {
return getColumnIndex(columnName).takeIf { it != -1 } ?: throw IllegalArgumentException(columnName)
}
override fun getColumnIndex(columnName: String) = columnNames.indexOf(columnName)
override fun moveToNext() = (currentRowIndex + 1 < content.size).also {
currentRowIndex += 1
}
override fun moveToFirst() = content.isNotEmpty()
override fun getCount() = content.size
override fun getString(index: Int): String? = content[currentRowIndex][columnNames[index]] as? String
override fun getInt(index: Int): Int {
return content[currentRowIndex][columnNames[index]] as? Int ?: throw IllegalArgumentException("Int can't be null")
}
override fun getLong(index: Int): Long {
return content[currentRowIndex][columnNames[index]] as? Long ?: throw IllegalArgumentException("Long can't be null")
}
override fun getColumnCount() = columnNames.size
override fun close() {
// do nothing
}
} }

View File

@ -39,6 +39,8 @@ class ReducerTestScope<S, E>(
private val expectTestScope: ExpectTestScope private val expectTestScope: ExpectTestScope
) : ExpectTestScope by expectTestScope, Reducer<S> { ) : ExpectTestScope by expectTestScope, Reducer<S> {
private var invalidateCapturedState: Boolean = false
private val actionSideEffects = mutableMapOf<Action, () -> S>()
private var manualState: S? = null private var manualState: S? = null
private var capturedResult: S? = null private var capturedResult: S? = null
@ -47,6 +49,10 @@ class ReducerTestScope<S, E>(
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher()) override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
override fun dispatch(action: Action) { override fun dispatch(action: Action) {
actionCaptures.add(action) actionCaptures.add(action)
if (actionSideEffects.containsKey(action)) {
setState(actionSideEffects.getValue(action).invoke(), invalidateCapturedState = true)
}
} }
override fun getState() = manualState ?: reducerFactory.initialState() override fun getState() = manualState ?: reducerFactory.initialState()
@ -54,15 +60,20 @@ class ReducerTestScope<S, E>(
private val reducer: Reducer<S> = reducerFactory.create(reducerScope) private val reducer: Reducer<S> = reducerFactory.create(reducerScope)
override fun reduce(action: Action) = reducer.reduce(action).also { override fun reduce(action: Action) = reducer.reduce(action).also {
capturedResult = it capturedResult = if (invalidateCapturedState) manualState else it
} }
fun setState(state: S) { fun actionSideEffect(action: Action, handler: () -> S) {
actionSideEffects[action] = handler
}
fun setState(state: S, invalidateCapturedState: Boolean = false) {
manualState = state manualState = state
this.invalidateCapturedState = invalidateCapturedState
} }
fun setState(block: (S) -> S) { fun setState(block: (S) -> S) {
manualState = block(reducerScope.getState()) setState(block(reducerScope.getState()))
} }
fun assertInitialState(expected: S) { fun assertInitialState(expected: S) {

View File

@ -1,37 +1,39 @@
package app.dapk.st.messenger.gallery package app.dapk.st.messenger.gallery
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri import android.net.Uri
import android.provider.MediaStore.Images import android.provider.MediaStore.Images
import app.dapk.st.core.ContentResolverQuery
import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.reduce
import app.dapk.st.core.withIoContext import app.dapk.st.core.withIoContext
class FetchMediaFoldersUseCase( class FetchMediaFoldersUseCase(
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
private val uriAvoidance: MediaUriAvoidance,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
) { ) {
suspend fun fetchFolders(): List<Folder> { suspend fun fetchFolders(): List<Folder> {
return dispatchers.withIoContext { return dispatchers.withIoContext {
val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED) val query = ContentResolverQuery(
val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?" uriAvoidance.externalContentUri,
val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC" listOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED),
"${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?",
listOf("%image/svg%"),
"${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC"
)
val folders = mutableMapOf<String, Folder>() contentResolver.reduce(query, mutableMapOf<String, Folder>()) { acc, cursor ->
val contentUri = Images.Media.EXTERNAL_CONTENT_URI val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media._ID))
contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor -> val thumbnail = uriAvoidance.uriAppender(query.uri, rowId)
while (cursor != null && cursor.moveToNext()) { val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.BUCKET_ID))
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])) val title = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.BUCKET_DISPLAY_NAME)) ?: ""
val thumbnail = ContentUris.withAppendedId(contentUri, rowId) val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED))
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])) val folder = acc.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
val title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])) ?: ""
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]))
val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
folder.incrementItemCount() folder.incrementItemCount()
} acc
} }.values.toList()
folders.values.toList()
} }
} }

View File

@ -1,15 +1,20 @@
package app.dapk.st.messenger.gallery package app.dapk.st.messenger.gallery
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import app.dapk.st.core.ContentResolverQuery
import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.reduce
import app.dapk.st.core.withIoContext import app.dapk.st.core.withIoContext
class FetchMediaUseCase(private val contentResolver: ContentResolver, private val dispatchers: CoroutineDispatchers) { class FetchMediaUseCase(
private val contentResolver: ContentResolver,
private val uriAvoidance: MediaUriAvoidance,
private val dispatchers: CoroutineDispatchers
) {
private val projection = arrayOf( private val projection = listOf(
MediaStore.Images.Media._ID, MediaStore.Images.Media._ID,
MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.DATE_MODIFIED,
@ -22,34 +27,33 @@ class FetchMediaUseCase(private val contentResolver: ContentResolver, private va
private val selection = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?" private val selection = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?"
suspend fun getMediaInBucket(bucketId: String): List<Media> { suspend fun getMediaInBucket(bucketId: String): List<Media> {
return dispatchers.withIoContext { return dispatchers.withIoContext {
val media = mutableListOf<Media>() val query = ContentResolverQuery(
val selectionArgs = arrayOf(bucketId, "%image/svg%") uri = uriAvoidance.externalContentUri,
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC" projection = projection,
val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI selection = selection,
contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor -> selectionArgs = listOf(bucketId, "%image/svg%"),
while (cursor != null && cursor.moveToNext()) { sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC",
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])) )
val uri = ContentUris.withAppendedId(contentUri, rowId)
contentResolver.reduce(query) { cursor ->
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
val uri = uriAvoidance.uriAppender(query.uri, rowId)
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)) val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)) val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION)) val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))) val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))) val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
media.add(Media(rowId, uri, mimetype, width, height, size, date)) Media(rowId, uri, mimetype, width, height, size, date)
} }
} }
media
}
} }
private fun getWidthColumn(orientation: Int) = if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT private fun getWidthColumn(orientation: Int) = if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT
private fun getHeightColumn(orientation: Int) = private fun getHeightColumn(orientation: Int) =
if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
} }
data class Media( data class Media(

View File

@ -1,7 +1,12 @@
package app.dapk.st.messenger.gallery package app.dapk.st.messenger.gallery
import android.content.ContentResolver import android.content.ContentResolver
import app.dapk.st.core.* import android.content.ContentUris
import android.provider.MediaStore
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.JobBag
import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.createStateViewModel
import app.dapk.st.messenger.gallery.state.ImageGalleryState import app.dapk.st.messenger.gallery.state.ImageGalleryState
import app.dapk.st.messenger.gallery.state.imageGalleryReducer import app.dapk.st.messenger.gallery.state.imageGalleryReducer
@ -11,10 +16,14 @@ class ImageGalleryModule(
) : ProvidableModule { ) : ProvidableModule {
fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel { fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel {
val uriAvoidance = MediaUriAvoidance(
uriAppender = { uri, rowId -> ContentUris.withAppendedId(uri, rowId) },
externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
)
imageGalleryReducer( imageGalleryReducer(
roomName = roomName, roomName = roomName,
FetchMediaFoldersUseCase(contentResolver, dispatchers), FetchMediaFoldersUseCase(contentResolver, uriAvoidance, dispatchers),
FetchMediaUseCase(contentResolver, dispatchers), FetchMediaUseCase(contentResolver, uriAvoidance, dispatchers),
JobBag(), JobBag(),
) )
} }

View File

@ -0,0 +1,8 @@
package app.dapk.st.messenger.gallery
import android.net.Uri
class MediaUriAvoidance(
val uriAppender: (Uri, Long) -> Uri,
val externalContentUri: Uri,
)

View File

@ -46,7 +46,6 @@ fun imageGalleryReducer(
parent = ImageGalleryPage.Routes.folders, parent = ImageGalleryPage.Routes.folders,
state = ImageGalleryPage.Files(Lce.Loading(), action.folder) state = ImageGalleryPage.Files(Lce.Loading(), action.folder)
) )
dispatch(PageAction.GoTo(page)) dispatch(PageAction.GoTo(page))
jobBag.replace(ImageGalleryPage.Files::class, coroutineScope.launch { jobBag.replace(ImageGalleryPage.Files::class, coroutineScope.launch {
@ -58,7 +57,7 @@ fun imageGalleryReducer(
}, },
sideEffect(PageStateChange.ChangePage::class) { action, _ -> sideEffect(PageStateChange.ChangePage::class) { action, _ ->
jobBag.cancel(action.previous::class) jobBag.cancel(action.previous.state::class)
}, },
) )
} }

View File

@ -0,0 +1,91 @@
package app.dapk.st.messenger.gallery
import android.net.Uri
import android.provider.MediaStore
import fake.CreateCursorScope
import fake.FakeContentResolver
import fake.FakeUri
import fake.createCursor
import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private val A_EXTERNAL_CONTENT_URI = FakeUri()
private const val A_BUCKET_ID = "a-bucket-id"
private const val A_SECOND_BUCKET_ID = "another-bucket"
private const val A_DISPLAY_NAME = "a-bucket-name"
private const val A_DATE_MODIFIED = 5000L
class FetchMediaFoldersUseCaseTest {
private val fakeContentResolver = FakeContentResolver()
private val fakeUriAppender = FakeUriAppender()
private val uriAvoidance = MediaUriAvoidance(
uriAppender = fakeUriAppender,
externalContentUri = A_EXTERNAL_CONTENT_URI.instance,
)
private val useCase = FetchMediaFoldersUseCase(fakeContentResolver.instance, uriAvoidance, aCoroutineDispatchers())
@Test
fun `given cursor content, when get folder, then reads unique folders`() = runTest {
fakeContentResolver.givenFolderQuery().returns(createCursor {
addFolderRow(rowId = 1, A_BUCKET_ID)
addFolderRow(rowId = 2, A_BUCKET_ID)
addFolderRow(rowId = 3, A_SECOND_BUCKET_ID)
})
val result = useCase.fetchFolders()
result shouldBeEqualTo listOf(
Folder(
bucketId = A_BUCKET_ID,
title = A_DISPLAY_NAME,
thumbnail = fakeUriAppender.get(rowId = 1),
),
Folder(
bucketId = A_SECOND_BUCKET_ID,
title = A_DISPLAY_NAME,
thumbnail = fakeUriAppender.get(rowId = 3),
),
)
result[0].itemCount shouldBeEqualTo 2
result[1].itemCount shouldBeEqualTo 1
}
private fun CreateCursorScope.addFolderRow(rowId: Long, bucketId: String) {
addRow(
MediaStore.Images.Media._ID to rowId,
MediaStore.Images.Media.BUCKET_ID to bucketId,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME to A_DISPLAY_NAME,
MediaStore.Images.Media.DATE_MODIFIED to A_DATE_MODIFIED,
)
}
}
private fun FakeContentResolver.givenFolderQuery() = this.givenQueryResult(
A_EXTERNAL_CONTENT_URI.instance,
arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.BUCKET_ID,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
MediaStore.Images.Media.DATE_MODIFIED
),
"${isNotPending()} AND ${MediaStore.Images.Media.BUCKET_ID} AND ${MediaStore.Images.Media.MIME_TYPE} NOT LIKE ?",
arrayOf("%image/svg%"),
"${MediaStore.Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${MediaStore.Images.Media.DATE_MODIFIED} DESC",
)
class FakeUriAppender : (Uri, Long) -> Uri {
private val uris = mutableMapOf<Long, FakeUri>()
override fun invoke(uri: Uri, rowId: Long): Uri {
val fakeUri = FakeUri()
uris[rowId] = fakeUri
return fakeUri.instance
}
fun get(rowId: Long) = uris[rowId]!!.instance
}

View File

@ -0,0 +1,107 @@
package app.dapk.st.messenger.gallery
import android.provider.MediaStore
import fake.FakeContentResolver
import fake.FakeUri
import fake.createCursor
import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private val A_EXTERNAL_CONTENT_URI = FakeUri()
private val ROW_URI = FakeUri()
private const val A_BUCKET_ID = "a-bucket-id"
private const val A_ROW_ID = 20L
private const val A_MIME_TYPE = "image/png"
private const val A_DATE_MODIFIED = 5000L
private const val A_SIZE = 1000L
private const val A_NORMAL_ORIENTATION = 0
private const val AN_INVERTED_ORIENTATION = 90
private const val A_WIDTH = 250
private const val A_HEIGHT = 750
class FetchMediaUseCaseTest {
private val fakeContentResolver = FakeContentResolver()
private val uriAvoidance = MediaUriAvoidance(
uriAppender = { _, _ -> ROW_URI.instance },
externalContentUri = A_EXTERNAL_CONTENT_URI.instance,
)
private val useCase = FetchMediaUseCase(fakeContentResolver.instance, uriAvoidance, aCoroutineDispatchers())
@Test
fun `given cursor content, when get media for bucket, then reads media`() = runTest {
fakeContentResolver.givenMediaQuery().returns(createCursor {
addRow(
MediaStore.Images.Media._ID to A_ROW_ID,
MediaStore.Images.Media.MIME_TYPE to A_MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED to A_DATE_MODIFIED,
MediaStore.Images.Media.ORIENTATION to A_NORMAL_ORIENTATION,
MediaStore.Images.Media.WIDTH to A_WIDTH,
MediaStore.Images.Media.HEIGHT to A_HEIGHT,
MediaStore.Images.Media.SIZE to A_SIZE,
)
})
val result = useCase.getMediaInBucket(A_BUCKET_ID)
result shouldBeEqualTo listOf(
Media(
id = A_ROW_ID,
uri = ROW_URI.instance,
mimeType = A_MIME_TYPE,
width = A_WIDTH,
height = A_HEIGHT,
size = A_SIZE,
dateModifiedEpochMillis = A_DATE_MODIFIED
)
)
}
@Test
fun `given cursor content with 90 degree orientation, when get media for bucket, then reads media with inverted width and height`() = runTest {
fakeContentResolver.givenMediaQuery().returns(createCursor {
addRow(
MediaStore.Images.Media._ID to A_ROW_ID,
MediaStore.Images.Media.MIME_TYPE to A_MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED to A_DATE_MODIFIED,
MediaStore.Images.Media.ORIENTATION to AN_INVERTED_ORIENTATION,
MediaStore.Images.Media.WIDTH to A_WIDTH,
MediaStore.Images.Media.HEIGHT to A_HEIGHT,
MediaStore.Images.Media.SIZE to A_SIZE,
)
})
val result = useCase.getMediaInBucket(A_BUCKET_ID)
result shouldBeEqualTo listOf(
Media(
id = A_ROW_ID,
uri = ROW_URI.instance,
mimeType = A_MIME_TYPE,
width = A_HEIGHT,
height = A_WIDTH,
size = A_SIZE,
dateModifiedEpochMillis = A_DATE_MODIFIED
)
)
}
}
private fun FakeContentResolver.givenMediaQuery() = this.givenQueryResult(
A_EXTERNAL_CONTENT_URI.instance,
arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED,
MediaStore.Images.Media.ORIENTATION,
MediaStore.Images.Media.WIDTH,
MediaStore.Images.Media.HEIGHT,
MediaStore.Images.Media.SIZE
),
MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?",
arrayOf(A_BUCKET_ID, "%image/svg%"),
MediaStore.Images.Media.DATE_MODIFIED + " DESC",
)

View File

@ -1,54 +1,131 @@
package app.dapk.st.messenger.gallery.state package app.dapk.st.messenger.gallery.state
import android.net.Uri
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.core.page.PageAction
import app.dapk.st.core.page.PageContainer
import app.dapk.st.core.page.PageStateChange
import app.dapk.st.design.components.SpiderPage import app.dapk.st.design.components.SpiderPage
import app.dapk.st.messenger.gallery.FetchMediaFoldersUseCase import app.dapk.st.messenger.gallery.FetchMediaFoldersUseCase
import app.dapk.st.messenger.gallery.FetchMediaUseCase import app.dapk.st.messenger.gallery.FetchMediaUseCase
import app.dapk.st.core.page.PageContainer import app.dapk.st.messenger.gallery.Folder
import app.dapk.st.messenger.gallery.Media
import app.dapk.state.Combined2 import app.dapk.state.Combined2
import fake.FakeJobBag import fake.FakeJobBag
import fake.FakeUri
import io.mockk.coEvery
import io.mockk.mockk import io.mockk.mockk
import org.junit.Test import org.junit.Test
import test.assertOnlyDispatches
import test.delegateReturn
import test.expect
import test.testReducer import test.testReducer
private const val A_ROOM_NAME = "a room name" private const val A_ROOM_NAME = "a room name"
private val A_FOLDER = Folder(
bucketId = "a-bucket-id",
title = "a title",
thumbnail = FakeUri().instance,
)
private val A_MEDIA_RESULT = listOf(aMedia())
private val A_FOLDERS_RESULT = listOf(aFolder())
private val AN_INITIAL_FILES_PAGE = SpiderPage(
route = ImageGalleryPage.Routes.files,
label = "Send to $A_ROOM_NAME",
parent = ImageGalleryPage.Routes.folders,
state = ImageGalleryPage.Files(Lce.Loading(), A_FOLDER)
)
private val AN_INITIAL_FOLDERS_PAGE = SpiderPage(
route = ImageGalleryPage.Routes.folders,
label = "Send to $A_ROOM_NAME",
parent = null,
state = ImageGalleryPage.Folders(Lce.Loading())
)
class ImageGalleryReducerTest { class ImageGalleryReducerTest {
private val fakeJobBag = FakeJobBag() private val fakeJobBag = FakeJobBag()
private val fakeFetchMediaFoldersUseCase = FakeFetchMediaFoldersUseCase()
private val fakeFetchMediaUseCase = FakeFetchMediaUseCase()
private val runReducerTest = testReducer { _: (Unit) -> Unit -> private val runReducerTest = testReducer { _: (Unit) -> Unit ->
imageGalleryReducer( imageGalleryReducer(
A_ROOM_NAME, A_ROOM_NAME,
FakeFetchMediaFoldersUseCase().instance, fakeFetchMediaFoldersUseCase.instance,
FakeFetchMediaUseCase().instance, fakeFetchMediaUseCase.instance,
fakeJobBag.instance, fakeJobBag.instance,
) )
} }
@Test @Test
fun `initial state is folders page`() = runReducerTest { fun `initial state is folders page`() = runReducerTest {
assertInitialState( assertInitialState(pageState(AN_INITIAL_FOLDERS_PAGE))
Combined2( }
state1 = PageContainer(
SpiderPage( @Test
route = ImageGalleryPage.Routes.folders, fun `when Visible, then updates Folders content`() = runReducerTest {
label = "Send to $A_ROOM_NAME", fakeJobBag.instance.expect { it.replace(ImageGalleryPage.Folders::class, any()) }
parent = null, fakeFetchMediaFoldersUseCase.givenFolders().returns(A_FOLDERS_RESULT)
state = ImageGalleryPage.Folders(Lce.Loading())
) reduce(ImageGalleryActions.Visible)
),
state2 = Unit assertOnlyDispatches(
) PageStateChange.UpdatePage(AN_INITIAL_FOLDERS_PAGE.state.copy(content = Lce.Content(A_FOLDERS_RESULT)))
) )
} }
@Test
fun `when SelectFolder, then goes to Folder page and fetches content`() = runReducerTest {
fakeJobBag.instance.expect { it.replace(ImageGalleryPage.Files::class, any()) }
fakeFetchMediaUseCase.givenMedia(A_FOLDER.bucketId).returns(A_MEDIA_RESULT)
val goToFolderPage = PageAction.GoTo(AN_INITIAL_FILES_PAGE)
actionSideEffect(goToFolderPage) { pageState(goToFolderPage.page) }
reduce(ImageGalleryActions.SelectFolder(A_FOLDER))
assertOnlyDispatches(
goToFolderPage,
PageStateChange.UpdatePage(goToFolderPage.page.state.copy(content = Lce.Content(A_MEDIA_RESULT)))
)
} }
@Test
fun `when ChangePage, then cancels previous page jobs`() = runReducerTest {
fakeJobBag.instance.expect { it.cancel(ImageGalleryPage.Folders::class) }
reduce(PageStateChange.ChangePage(previous = AN_INITIAL_FOLDERS_PAGE, newPage = AN_INITIAL_FILES_PAGE))
assertOnlyStateChange(pageState(AN_INITIAL_FILES_PAGE))
}
}
private fun <P> pageState(page: SpiderPage<out P>) = Combined2(PageContainer(page), Unit)
class FakeFetchMediaFoldersUseCase { class FakeFetchMediaFoldersUseCase {
val instance = mockk<FetchMediaFoldersUseCase>() val instance = mockk<FetchMediaFoldersUseCase>()
fun givenFolders() = coEvery { instance.fetchFolders() }.delegateReturn()
} }
class FakeFetchMediaUseCase { class FakeFetchMediaUseCase {
val instance = mockk<FetchMediaUseCase>() val instance = mockk<FetchMediaUseCase>()
fun givenMedia(bucketId: String) = coEvery { instance.getMediaInBucket(bucketId) }.delegateReturn()
} }
fun aMedia(
id: Long = 1L,
uri: Uri = FakeUri().instance,
mimeType: String = "image/png",
width: Int = 100,
height: Int = 250,
size: Long = 1000L,
dateModifiedEpochMillis: Long = 5000L,
) = Media(id, uri, mimeType, width, height, size, dateModifiedEpochMillis)
fun aFolder(
bucketId: String = "a-bucket-id",
title: String = "a title",
thumbnail: Uri = FakeUri().instance,
) = Folder(bucketId, title, thumbnail)