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 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.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
|
||||
) : ExpectTestScope by expectTestScope, Reducer<S> {
|
||||
|
||||
private var invalidateCapturedState: Boolean = false
|
||||
private val actionSideEffects = mutableMapOf<Action, () -> S>()
|
||||
private var manualState: S? = null
|
||||
private var capturedResult: S? = null
|
||||
|
||||
|
@ -47,6 +49,10 @@ class ReducerTestScope<S, E>(
|
|||
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
|
||||
override fun dispatch(action: Action) {
|
||||
actionCaptures.add(action)
|
||||
|
||||
if (actionSideEffects.containsKey(action)) {
|
||||
setState(actionSideEffects.getValue(action).invoke(), invalidateCapturedState = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState() = manualState ?: reducerFactory.initialState()
|
||||
|
@ -54,15 +60,20 @@ class ReducerTestScope<S, E>(
|
|||
private val reducer: Reducer<S> = reducerFactory.create(reducerScope)
|
||||
|
||||
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
|
||||
this.invalidateCapturedState = invalidateCapturedState
|
||||
}
|
||||
|
||||
fun setState(block: (S) -> S) {
|
||||
manualState = block(reducerScope.getState())
|
||||
setState(block(reducerScope.getState()))
|
||||
}
|
||||
|
||||
fun assertInitialState(expected: S) {
|
||||
|
|
|
@ -1,37 +1,39 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore.Images
|
||||
import app.dapk.st.core.ContentResolverQuery
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.reduce
|
||||
import app.dapk.st.core.withIoContext
|
||||
|
||||
class FetchMediaFoldersUseCase(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val uriAvoidance: MediaUriAvoidance,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
|
||||
suspend fun fetchFolders(): List<Folder> {
|
||||
return dispatchers.withIoContext {
|
||||
val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED)
|
||||
val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?"
|
||||
val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC"
|
||||
val query = ContentResolverQuery(
|
||||
uriAvoidance.externalContentUri,
|
||||
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>()
|
||||
val contentUri = Images.Media.EXTERNAL_CONTENT_URI
|
||||
contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
|
||||
val thumbnail = ContentUris.withAppendedId(contentUri, rowId)
|
||||
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1]))
|
||||
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) }
|
||||
contentResolver.reduce(query, mutableMapOf<String, Folder>()) { acc, cursor ->
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media._ID))
|
||||
val thumbnail = uriAvoidance.uriAppender(query.uri, rowId)
|
||||
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.BUCKET_ID))
|
||||
val title = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.BUCKET_DISPLAY_NAME)) ?: ""
|
||||
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED))
|
||||
val folder = acc.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
|
||||
folder.incrementItemCount()
|
||||
}
|
||||
}
|
||||
folders.values.toList()
|
||||
acc
|
||||
}.values.toList()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import app.dapk.st.core.ContentResolverQuery
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.reduce
|
||||
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.MIME_TYPE,
|
||||
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 ?"
|
||||
|
||||
suspend fun getMediaInBucket(bucketId: String): List<Media> {
|
||||
|
||||
return dispatchers.withIoContext {
|
||||
val media = mutableListOf<Media>()
|
||||
val selectionArgs = arrayOf(bucketId, "%image/svg%")
|
||||
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
|
||||
val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
|
||||
val uri = ContentUris.withAppendedId(contentUri, rowId)
|
||||
val query = ContentResolverQuery(
|
||||
uri = uriAvoidance.externalContentUri,
|
||||
projection = projection,
|
||||
selection = selection,
|
||||
selectionArgs = listOf(bucketId, "%image/svg%"),
|
||||
sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC",
|
||||
)
|
||||
|
||||
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 date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
|
||||
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
|
||||
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.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 getHeightColumn(orientation: Int) =
|
||||
if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
|
||||
|
||||
}
|
||||
|
||||
data class Media(
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
package app.dapk.st.messenger.gallery
|
||||
|
||||
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.imageGalleryReducer
|
||||
|
||||
|
@ -11,10 +16,14 @@ class ImageGalleryModule(
|
|||
) : ProvidableModule {
|
||||
|
||||
fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel {
|
||||
val uriAvoidance = MediaUriAvoidance(
|
||||
uriAppender = { uri, rowId -> ContentUris.withAppendedId(uri, rowId) },
|
||||
externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
)
|
||||
imageGalleryReducer(
|
||||
roomName = roomName,
|
||||
FetchMediaFoldersUseCase(contentResolver, dispatchers),
|
||||
FetchMediaUseCase(contentResolver, dispatchers),
|
||||
FetchMediaFoldersUseCase(contentResolver, uriAvoidance, dispatchers),
|
||||
FetchMediaUseCase(contentResolver, uriAvoidance, dispatchers),
|
||||
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,
|
||||
state = ImageGalleryPage.Files(Lce.Loading(), action.folder)
|
||||
)
|
||||
|
||||
dispatch(PageAction.GoTo(page))
|
||||
|
||||
jobBag.replace(ImageGalleryPage.Files::class, coroutineScope.launch {
|
||||
|
@ -58,7 +57,7 @@ fun imageGalleryReducer(
|
|||
},
|
||||
|
||||
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
|
||||
|
||||
import android.net.Uri
|
||||
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.messenger.gallery.FetchMediaFoldersUseCase
|
||||
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 fake.FakeJobBag
|
||||
import fake.FakeUri
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
import test.assertOnlyDispatches
|
||||
import test.delegateReturn
|
||||
import test.expect
|
||||
import test.testReducer
|
||||
|
||||
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 {
|
||||
|
||||
private val fakeJobBag = FakeJobBag()
|
||||
private val fakeFetchMediaFoldersUseCase = FakeFetchMediaFoldersUseCase()
|
||||
private val fakeFetchMediaUseCase = FakeFetchMediaUseCase()
|
||||
|
||||
private val runReducerTest = testReducer { _: (Unit) -> Unit ->
|
||||
imageGalleryReducer(
|
||||
A_ROOM_NAME,
|
||||
FakeFetchMediaFoldersUseCase().instance,
|
||||
FakeFetchMediaUseCase().instance,
|
||||
fakeFetchMediaFoldersUseCase.instance,
|
||||
fakeFetchMediaUseCase.instance,
|
||||
fakeJobBag.instance,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is folders page`() = runReducerTest {
|
||||
assertInitialState(
|
||||
Combined2(
|
||||
state1 = PageContainer(
|
||||
SpiderPage(
|
||||
route = ImageGalleryPage.Routes.folders,
|
||||
label = "Send to $A_ROOM_NAME",
|
||||
parent = null,
|
||||
state = ImageGalleryPage.Folders(Lce.Loading())
|
||||
)
|
||||
),
|
||||
state2 = Unit
|
||||
)
|
||||
assertInitialState(pageState(AN_INITIAL_FOLDERS_PAGE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Visible, then updates Folders content`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.replace(ImageGalleryPage.Folders::class, any()) }
|
||||
fakeFetchMediaFoldersUseCase.givenFolders().returns(A_FOLDERS_RESULT)
|
||||
|
||||
reduce(ImageGalleryActions.Visible)
|
||||
|
||||
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 {
|
||||
val instance = mockk<FetchMediaFoldersUseCase>()
|
||||
|
||||
fun givenFolders() = coEvery { instance.fetchFolders() }.delegateReturn()
|
||||
}
|
||||
|
||||
class FakeFetchMediaUseCase {
|
||||
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