hltb example: move game finder logic out of fragment, introduce mockk unit tests

This commit is contained in:
Wouter Groeneveld 2021-08-19 17:41:02 +02:00
parent 6242c4a0a1
commit f1c576ffde
10 changed files with 243 additions and 85 deletions

View File

@ -64,6 +64,8 @@ dependencies {
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
testImplementation("io.mockk:mockk:1.12.0")
// --- kotlinx extras
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1")

View File

@ -10,14 +10,14 @@ import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
class HLTBClientTest {
class HLTBClientImplTest {
private lateinit var client: HLTBClient
private lateinit var client: HLTBClientImpl
@Before
fun setUp() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
client = HLTBClient(appContext)
client = HLTBClientImpl(appContext)
}
@Test

View File

@ -14,14 +14,9 @@ import androidx.core.content.PermissionChecker
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import be.kuleuven.howlongtobeat.cartridges.CartridgeFinderViaDuckDuckGo
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepositoryGekkioFi
import be.kuleuven.howlongtobeat.cartridges.findFirstCartridgeForRepos
import be.kuleuven.howlongtobeat.databinding.FragmentLoadingBinding
import be.kuleuven.howlongtobeat.google.GoogleVisionClient
import be.kuleuven.howlongtobeat.hltb.HLTBClient
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import be.kuleuven.howlongtobeat.model.GameFinder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
@ -29,10 +24,6 @@ import java.io.File
class LoadingFragment : Fragment(R.layout.fragment_loading) {
private lateinit var hltbClient: HLTBClient
private lateinit var cartRepos: List<CartridgesRepository>
private lateinit var imageRecognizer: ImageRecognizer
private lateinit var cameraPermissionActivityResult: ActivityResultLauncher<String>
private lateinit var cameraActivityResult: ActivityResultLauncher<Uri>
private lateinit var main: MainActivity
@ -48,14 +39,6 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
binding = FragmentLoadingBinding.inflate(layoutInflater)
main = activity as MainActivity
// If we fail to find info in the first repo, it falls back to the second one: a (scraped) DuckDuckGo search.
cartRepos = listOf(
CartridgesRepositoryGekkioFi.fromAsset(main.applicationContext),
CartridgeFinderViaDuckDuckGo(main.applicationContext)
)
imageRecognizer = GoogleVisionClient()
hltbClient = HLTBClient(main.applicationContext)
cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicture(), this::cameraSnapTaken)
cameraPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission(), this::cameraPermissionAcceptedOrDenied)
binding.btnRetryAfterLoading.setOnClickListener {
@ -105,19 +88,11 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
// Uncomment this line if you want to stub out camera pictures
// picToAnalyze = BitmapFactory.decodeResource(resources, R.drawable.sml2)
progress("Recognizing game cart from picture...")
val cartCode = imageRecognizer.recognizeCartCode(picToAnalyze)
?: throw UnableToFindGameException("No cart code in your pic found")
val hltbResults = GameFinder.default(main.applicationContext).findGameBasedOnCameraSnap(picToAnalyze) {
progress(it)
}
progress("Found cart code $cartCode\nLooking in DBs for matching game...")
val foundCart = findFirstCartridgeForRepos(cartCode, cartRepos)
?: throw UnableToFindGameException("$cartCode is an unknown game cart.")
progress("Valid cart code: $cartCode\n Looking in HLTB for ${foundCart.title}...")
val hltbResults = hltbClient.find(foundCart)
?: throw UnableToFindGameException("HLTB does not know ${foundCart.title}")
Snackbar.make(requireView(), "Found ${hltbResults.size} game(s) for cart ${foundCart.code}", Snackbar.LENGTH_LONG).show()
Snackbar.make(requireView(), "Found ${hltbResults.size} game(s)", Snackbar.LENGTH_LONG).show()
val bundle = bundleOf(
HowLongToBeatResult.RESULT to hltbResults,
HowLongToBeatResult.SNAPSHOT_URI to snapshot.toString()

View File

@ -8,11 +8,14 @@ object DuckDuckGoResultParser {
".",
"-",
"/",
"\n",
",",
"!",
"Get information and compare prices of",
"for Game Boy",
"Release Information",
"Release Dates",
"Mobygames",
"Nintendo",
"Game Boy Advance",
"Game Boy color",

View File

@ -1,54 +1,7 @@
package be.kuleuven.howlongtobeat.hltb
import android.content.Context
import be.kuleuven.howlongtobeat.cartridges.Cartridge
import com.android.volley.Response
import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley
import kotlin.coroutines.suspendCoroutine
class HLTBClient(val context: Context) {
companion object {
const val DOMAIN = "https://howlongtobeat.com"
}
// Inspired by https://www.npmjs.com/package/howlongtobeat
// The API is abysmal, but hey, it works...
class HLTBRequest(val query: String, responseListener: Response.Listener<String>) :
StringRequest(Method.POST, "$DOMAIN/search_results.php?page=1", responseListener,
Response.ErrorListener {
println("Something went wrong: ${it.message}")
}) {
override fun getBodyContentType(): String {
return "application/x-www-form-urlencoded"
}
override fun getParams(): MutableMap<String, String> {
return hashMapOf(
"queryString" to query,
"t" to "games",
"sorthead" to "popular",
"sortd" to "0",
"plat" to "",
"length_type" to "main",
"length_min" to "",
"length_max" to "",
"detail" to "0"
)
}
override fun getHeaders(): MutableMap<String, String> {
return hashMapOf("User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0")
}
}
suspend fun find(cart: Cartridge): List<HowLongToBeatResult>? = suspendCoroutine { cont ->
val queue = Volley.newRequestQueue(context)
val req = HLTBRequest(cart.title) {
val hltbResults = HowLongToBeatResultParser.parse(it, cart)
cont.resumeWith(Result.success(hltbResults))
}
queue.add(req)
}
}
interface HLTBClient {
suspend fun find(cart: Cartridge): List<HowLongToBeatResult>?
}

View File

@ -0,0 +1,54 @@
package be.kuleuven.howlongtobeat.hltb
import android.content.Context
import be.kuleuven.howlongtobeat.cartridges.Cartridge
import com.android.volley.Response
import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley
import kotlin.coroutines.suspendCoroutine
class HLTBClientImpl(val context: Context) : HLTBClient {
companion object {
const val DOMAIN = "https://howlongtobeat.com"
}
// Inspired by https://www.npmjs.com/package/howlongtobeat
// The API is abysmal, but hey, it works...
class HLTBRequest(val query: String, responseListener: Response.Listener<String>) :
StringRequest(Method.POST, "$DOMAIN/search_results.php?page=1", responseListener,
Response.ErrorListener {
println("Something went wrong: ${it.message}")
}) {
override fun getBodyContentType(): String {
return "application/x-www-form-urlencoded"
}
override fun getParams(): MutableMap<String, String> {
return hashMapOf(
"queryString" to query,
"t" to "games",
"sorthead" to "popular",
"sortd" to "0",
"plat" to "",
"length_type" to "main",
"length_min" to "",
"length_max" to "",
"detail" to "0"
)
}
override fun getHeaders(): MutableMap<String, String> {
return hashMapOf("User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0")
}
}
override suspend fun find(cart: Cartridge): List<HowLongToBeatResult>? = suspendCoroutine { cont ->
val queue = Volley.newRequestQueue(context)
val req = HLTBRequest(cart.title) {
val hltbResults = HowLongToBeatResultParser.parse(it, cart)
cont.resumeWith(Result.success(hltbResults))
}
queue.add(req)
}
}

View File

@ -10,7 +10,7 @@ data class HowLongToBeatResult(val title: String, val cartCode: String, val howl
const val SNAPSHOT_URI = "SnapshotUri"
}
fun hasBoxart(): Boolean = boxartUrl.startsWith(HLTBClient.DOMAIN)
fun hasBoxart(): Boolean = boxartUrl.startsWith(HLTBClientImpl.DOMAIN)
fun boxartUrl(): URL = URL(boxartUrl)
override fun toString(): String = "$title ($howlong hrs)"
}

View File

@ -30,7 +30,7 @@ object HowLongToBeatResultParser {
if(row - 3 >= 0) {
val matchedBoxArt = boxArtMatcher.find(rows[row - 3])
if(matchedBoxArt != null) {
return HLTBClient.DOMAIN + matchedBoxArt.groupValues[1]
return HLTBClientImpl.DOMAIN + matchedBoxArt.groupValues[1]
}
}
return ""

View File

@ -0,0 +1,51 @@
package be.kuleuven.howlongtobeat.model
import android.content.Context
import android.graphics.Bitmap
import be.kuleuven.howlongtobeat.ImageRecognizer
import be.kuleuven.howlongtobeat.UnableToFindGameException
import be.kuleuven.howlongtobeat.cartridges.CartridgeFinderViaDuckDuckGo
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepositoryGekkioFi
import be.kuleuven.howlongtobeat.cartridges.findFirstCartridgeForRepos
import be.kuleuven.howlongtobeat.google.GoogleVisionClient
import be.kuleuven.howlongtobeat.hltb.HLTBClient
import be.kuleuven.howlongtobeat.hltb.HLTBClientImpl
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
/**
* This class separates Android-specific logic with our own domain logic.
* WHY? Because of GameFinderTest and mockability!
*/
class GameFinder(
private val hltbClient: HLTBClient,
private val cartRepos: List<CartridgesRepository>,
private val imageRecognizer: ImageRecognizer) {
companion object {
fun default(context: Context): GameFinder {
// If we fail to find info in the first repo, it falls back to the second one: a (scraped) DuckDuckGo search.
val cartRepos = listOf(
CartridgesRepositoryGekkioFi.fromAsset(context),
CartridgeFinderViaDuckDuckGo(context)
)
return GameFinder(HLTBClientImpl(context), cartRepos, GoogleVisionClient())
}
}
suspend fun findGameBasedOnCameraSnap(picToAnalyze: Bitmap, progress: (msg: String) -> Unit): List<HowLongToBeatResult> {
progress("Recognizing game cart from picture...")
val cartCode = imageRecognizer.recognizeCartCode(picToAnalyze)
?: throw UnableToFindGameException("No cart code in your pic found")
progress("Found cart code $cartCode\nLooking in DBs for matching game...")
val foundCart = findFirstCartridgeForRepos(cartCode, cartRepos)
?: throw UnableToFindGameException("$cartCode is an unknown game cart.")
progress("Valid cart code: $cartCode\n Looking in HLTB for ${foundCart.title}...")
val hltbResults = hltbClient.find(foundCart)
?: throw UnableToFindGameException("HLTB does not know ${foundCart.title}")
return hltbResults
}
}

View File

@ -0,0 +1,120 @@
package be.kuleuven.howlongtobeat.model
import be.kuleuven.howlongtobeat.ImageRecognizer
import be.kuleuven.howlongtobeat.UnableToFindGameException
import be.kuleuven.howlongtobeat.cartridges.Cartridge
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository
import be.kuleuven.howlongtobeat.hltb.HLTBClient
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import java.util.concurrent.Executors
class GameFinderTest {
private lateinit var finder: GameFinder
@MockK
private lateinit var hltbClient: HLTBClient
@MockK
private lateinit var imageRecognizer: ImageRecognizer
@MockK
private lateinit var cartridgesRepository: CartridgesRepository
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
@Before fun setUp() {
MockKAnnotations.init(this)
finder = GameFinder(hltbClient, listOf(cartridgesRepository), imageRecognizer)
Dispatchers.setMain(dispatcher)
}
@After fun tearDown() {
Dispatchers.resetMain()
dispatcher.close()
}
@Test fun findGameBasedOnCameraSnap_UnrecognizableAccordingToGoogleVision_fails() = runBlocking {
coEvery { imageRecognizer.recognizeCartCode(any()) } returns null
try {
finder.findGameBasedOnCameraSnap(mockk()) {
}
Assert.fail("Expected exception to occur")
} catch(expected: UnableToFindGameException) {
Assert.assertEquals("No cart code in your pic found", expected.message)
}
}
@Test fun findGameBasedOnCameraSnap_UnknownCartridgeAccordingToDBs_fails() = runBlocking {
coEvery { imageRecognizer.recognizeCartCode(any()) } returns "DMG-MQ-EUR"
coEvery { cartridgesRepository.find("DMG-MQ-EUR") } returns null
try {
finder.findGameBasedOnCameraSnap(mockk()) {
}
Assert.fail("Expected exception to occur")
} catch(expected: UnableToFindGameException) {
Assert.assertEquals("DMG-MQ-EUR is an unknown game cart.", expected.message)
}
}
@Test fun findGameBasedOnCameraSnap_UnknownCartridgeAccordingToFirstDBButSecondFindsIt_returnsHltbResults() = runBlocking {
val secondCartridgeDb = mockk<CartridgesRepository>()
val cart = Cartridge("type", "Mario Land 356", "DMG-MQ-EUR")
coEvery { imageRecognizer.recognizeCartCode(any()) } returns cart.code
coEvery { cartridgesRepository.find(cart.code) } returns null
coEvery { secondCartridgeDb.find(cart.code) } returns cart
coEvery { hltbClient.find(cart) } returns listOf(HowLongToBeatResult(cart.title, cart.code, 34.5))
finder = GameFinder(hltbClient, listOf(cartridgesRepository, secondCartridgeDb), imageRecognizer)
val foundGames = finder.findGameBasedOnCameraSnap(mockk()) {
}
assertEquals(1, foundGames.size)
assertEquals(34.5, foundGames.single().howlong)
}
@Test fun findGameBasedOnCameraSnap_UnknownGameAccordingToHLTB_fails() = runBlocking {
val cart = Cartridge("type", "Mario Land 356", "DMG-MQ-EUR")
coEvery { imageRecognizer.recognizeCartCode(any()) } returns cart.code
coEvery { cartridgesRepository.find(cart.code) } returns cart
coEvery { hltbClient.find(cart) } returns null
try {
finder.findGameBasedOnCameraSnap(mockk()) {
}
Assert.fail("Expected exception to occur")
} catch(expected: UnableToFindGameException) {
Assert.assertEquals("HLTB does not know Mario Land 356", expected.message)
}
}
@Test fun findGameBasedOnCameraSnap_validGame_returnsHltbResults() = runBlocking {
val cart = Cartridge("type", "Mario Land 356", "DMG-MQ-EUR")
coEvery { imageRecognizer.recognizeCartCode(any()) } returns cart.code
coEvery { cartridgesRepository.find(cart.code) } returns cart
coEvery { hltbClient.find(cart) } returns listOf(HowLongToBeatResult(cart.title, cart.code, 34.5))
val foundGames = finder.findGameBasedOnCameraSnap(mockk()) {
}
assertEquals(1, foundGames.size)
assertEquals(34.5, foundGames.single().howlong)
}
}