hltb example: move game finder logic out of fragment, introduce mockk unit tests
This commit is contained in:
parent
6242c4a0a1
commit
f1c576ffde
|
@ -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")
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>?
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)"
|
||||
}
|
|
@ -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 ""
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue