Merge pull request #244 from ouchadam/tech/gallery-tests
Tech/Image Gallery Tests
This commit is contained in:
commit
78a4cbd140
|
@ -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
|
||||||
|
}
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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])) ?: ""
|
folder.incrementItemCount()
|
||||||
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]))
|
acc
|
||||||
val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
|
}.values.toList()
|
||||||
folder.incrementItemCount()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
folders.values.toList()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,26 +27,26 @@ 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)
|
|
||||||
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
|
contentResolver.reduce(query) { cursor ->
|
||||||
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
|
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
|
||||||
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
|
val uri = uriAvoidance.uriAppender(query.uri, rowId)
|
||||||
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
|
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
|
||||||
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
|
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
|
||||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
|
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
|
||||||
media.add(Media(rowId, uri, mimetype, width, height, size, date))
|
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
|
||||||
}
|
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
|
||||||
|
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
|
||||||
|
Media(rowId, uri, mimetype, width, height, size, date)
|
||||||
}
|
}
|
||||||
media
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,7 +54,6 @@ class FetchMediaUseCase(private val contentResolver: ContentResolver, private va
|
||||||
|
|
||||||
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(
|
||||||
|
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package app.dapk.st.messenger.gallery
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
class MediaUriAvoidance(
|
||||||
|
val uriAppender: (Uri, Long) -> Uri,
|
||||||
|
val externalContentUri: Uri,
|
||||||
|
)
|
|
@ -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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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",
|
||||||
|
)
|
|
@ -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)
|
Loading…
Reference in New Issue