hltb example: refactor find game progression system, add duckduckgo as fallback game repository
This commit is contained in:
parent
adde1e8e70
commit
561756ed6f
|
@ -54,7 +54,8 @@ dependencies {
|
|||
|
||||
// --- kotlinx extras
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1")
|
||||
|
||||
// --- navigation
|
||||
// see https://developer.android.com/guide/navigation/navigation-getting-started
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
package be.kuleuven.howlongtobeat.cartridges
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CartridgeFinderViaDuckDuckGoTest {
|
||||
|
||||
private lateinit var repo: CartridgeFinderViaDuckDuckGo
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
repo = CartridgeFinderViaDuckDuckGo(appContext)
|
||||
// no need to set the main dispatcher, it's running on the device itself!
|
||||
}
|
||||
|
||||
@Test
|
||||
fun find_knownCodesForDuckDuckGo_returnsCorrectCartridge() = runBlocking {
|
||||
launch(Dispatchers.Main) {
|
||||
val smbDeluxe = repo.find("CGB-AHYE-USA")
|
||||
assertEquals("super mario bros deluxe", smbDeluxe?.title)
|
||||
assertEquals("CGB-AHYE-USA", smbDeluxe?.code)
|
||||
|
||||
val marioGolf = repo.find("cgb-awxp-eur-1")
|
||||
assertEquals("mario golf", marioGolf?.title)
|
||||
assertEquals("cgb-awxp-eur-1", marioGolf?.code)
|
||||
}
|
||||
println("launched main dispatcher") // this must be there: Kotlin-to-Java doesn't recognize the retval because of runBlocking.
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package be.kuleuven.howlongtobeat.hltb
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import be.kuleuven.howlongtobeat.cartridges.Cartridge
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class HLTBClientTest {
|
||||
|
||||
private lateinit var client: HLTBClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
client = HLTBClient(appContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun find_unknownGame_returnsNull() = runBlocking {
|
||||
launch(Dispatchers.Main) {
|
||||
val results = client.find(Cartridge("type", "moesjamarramarra tis niet omdat ik wijs dat ge moet kijken he", "invalid"))
|
||||
assertNull(results)
|
||||
}
|
||||
println("Dispatche launched")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun find_someValidGame_retrievesSomeGamesBasedOnTitle() = runBlocking {
|
||||
launch(Dispatchers.Main) {
|
||||
val results = client.find(Cartridge("type", "Gex: Enter the Gecko", "CGB-GEX-666"))
|
||||
assertEquals(1, results?.size)
|
||||
val gex = results?.single()!!
|
||||
|
||||
assertEquals("Gex Enter the Gecko", gex.title)
|
||||
assertEquals("CGB-GEX-666", gex.cartCode)
|
||||
assertEquals(9.0, gex.howlong) // that long, huh.
|
||||
}
|
||||
println("Dispatche launched")
|
||||
}
|
||||
|
||||
}
|
|
@ -33,20 +33,20 @@ class GamePersistenceTests {
|
|||
|
||||
@Test
|
||||
fun todoItemCanBePersisted() {
|
||||
val item = Game("brush my little pony", false)
|
||||
val item = Game("brush my little pony","code", 0.0, false)
|
||||
dao.insert(arrayListOf(item))
|
||||
|
||||
val refreshedItem = dao.query().single()
|
||||
with(refreshedItem) {
|
||||
assertEquals(item.title, title)
|
||||
assertEquals(item.isDone, isDone)
|
||||
assertEquals(item.finished, finished)
|
||||
assertEquals(1, id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateUpdatesTodoPropertiesInDb() {
|
||||
var todo = Game("git good at Hollow Knight", false)
|
||||
var todo = Game("git good at Hollow Knight", "code", 10.5, false)
|
||||
dao.insert(arrayListOf(todo))
|
||||
todo = dao.query().single() // refresh to get the ID, otherwise update() will update where ID = 0
|
||||
|
||||
|
@ -57,12 +57,12 @@ class GamePersistenceTests {
|
|||
assertEquals(1, itemsFromDb.size)
|
||||
with(itemsFromDb.single()) {
|
||||
assertEquals(todo.title, title)
|
||||
assertEquals(true, isDone)
|
||||
assertEquals(true, finished)
|
||||
}
|
||||
}
|
||||
|
||||
private fun finallyFinishHollowKnight(item: Game) {
|
||||
println("Congrats! On to Demon Souls?")
|
||||
item.check()
|
||||
item.finish()
|
||||
}
|
||||
}
|
|
@ -293,6 +293,8 @@ DMG-MLA-1,Super Mario Land (World) (Rev 1),Entry #1,gekkio-1,https://gbhwdb.gekk
|
|||
DMG-MLA-1,Super Mario Land (World) (Rev 1),Entry #2,gekkio-2,https://gbhwdb.gekkio.fi/cartridges/DMG-MLA-1/gekkio-2.html,gekkio,DMG-ML-ITA,20A,DMG-BEAN-02,I,6,Jun/1996,June/1996,1996,6,LH53517,DMG-MLA-1 S LH531720 JAPAN B1 9627 D,Sharp,Sharp,27/1996,Week 27/1996,1996,,27,MBC1B1,DMG MBC1B1 Nintendo S 9625 3 A,Sharp,Sharp,25/1996,Week 25/1996,1996,,25,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
DMG-MLA-1,Super Mario Land (World) (Rev 1),Entry #3,gekkio-3,https://gbhwdb.gekkio.fi/cartridges/DMG-MLA-1/gekkio-3.html,gekkio,DMG-MLA,22A,DMG-BEAN-02,B,5,Jul/1993,July/1993,1993,7,LH53514,DMG-MLA-1 S LH5314B2 JAPAN B1 9339 E,Sharp,Sharp,39/1993,Week 39/1993,1993,,39,MBC1B,DMG MBC1B Nintendo J9330BR,Motorola,Motorola,30/1993,Week 30/1993,1993,,30,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
DMG-MLA-1,Super Mario Land (World) (Rev 1),Entry #1,tobiasvl-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MLA-1/tobiasvl-1.html,tobiasvl,DMG-ML-USA-1,22A,DMG-BEAN-10,I,5,Jul/1998,July/1998,1998,7,LH53517,DMG-MLA-1 S LH531720 JAPAN B1 9836 D,Sharp,Sharp,36/1998,Week 36/1998,1998,,36,MBC1B,DMG MBC1-B Nintendo P 8'59,,,1998,1998,1998,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
DMG-MQE-0,"Super Mario Land 2 - 6 Golden Coins (Europe)",Entry #1,max-m-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/max-m-1.html,max-m,DMG-MQ-EUR,00,DMG-DECN-02,I,2,Sep/1992,September/1992,1992,9,LH534M,DMG-MQE-0 S LH534M02 JAPAN E1 9243 D,Sharp,Sharp,43/1992,Week 43/1992,1992,,43,MBC1B,Nintendo DMG MBC1B N 9221BA041,,,21/1992,Week 21/1992,1992,,21,LH5168NFB-10TL,LH5168NFB-10TL LSI LOGIC JAPAN D242 7 BC,LSI Logic,LSI Logic,42/1992,Week 42/1992,1992,,42,MM1026A,295 26A,Mitsumi,Mitsumi,1992,1992,1992,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
DMG-MQE-0,"Super Mario Land 2 - 6 Golden Coins (USA)",Entry #1,max-m-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/max-m-1.html,max-m,DMG-MO-USA,00,DMG-DECN-02,I,2,Sep/1992,September/1992,1992,9,LH534M,DMG-MQE-0 S LH534M02 JAPAN E1 9243 D,Sharp,Sharp,43/1992,Week 43/1992,1992,,43,MBC1B,Nintendo DMG MBC1B N 9221BA041,,,21/1992,Week 21/1992,1992,,21,LH5168NFB-10TL,LH5168NFB-10TL LSI LOGIC JAPAN D242 7 BC,LSI Logic,LSI Logic,42/1992,Week 42/1992,1992,,42,MM1026A,295 26A,Mitsumi,Mitsumi,1992,1992,1992,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
DMG-MQE-0,"Super Mario Land 2 - 6 Golden Coins (USA, Europe)",Entry #1,max-m-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/max-m-1.html,max-m,DMG-MQ-NOE,00,DMG-DECN-02,I,2,Sep/1992,September/1992,1992,9,LH534M,DMG-MQE-0 S LH534M02 JAPAN E1 9243 D,Sharp,Sharp,43/1992,Week 43/1992,1992,,43,MBC1B,Nintendo DMG MBC1B N 9221BA041,,,21/1992,Week 21/1992,1992,,21,LH5168NFB-10TL,LH5168NFB-10TL LSI LOGIC JAPAN D242 7 BC,LSI Logic,LSI Logic,42/1992,Week 42/1992,1992,,42,MM1026A,295 26A,Mitsumi,Mitsumi,1992,1992,1992,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
DMG-MQE-0,"Super Mario Land 2 - 6 Golden Coins (USA, Europe)",Entry #1,tobiasvl-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/tobiasvl-1.html,tobiasvl,DMG-MQ-UKV,23,DMG-DECN-02,I,2,Nov/1992,November/1992,1992,11,LH534M,DMG-MQE-0 S LH534M02 JAPAN E1 9248 D,Sharp,Sharp,48/1992,Week 48/1992,1992,,48,MBC1B,Nintendo DMG MBC1B N 9245BA035,,,45/1992,Week 45/1992,1992,,45,LH5168NFB-10TL,LH5168NFB-10TL LSI LOGIC JAPAN D244 7 BC,LSI Logic,LSI Logic,44/1992,Week 44/1992,1992,,44,MM1026A,2J5 26A,Mitsumi,Mitsumi,1992,1992,1992,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
DMG-MQE-2,"Super Mario Land 2 - 6 Golden Coins (USA, Europe) (Rev 2)",Entry #1,creeps-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-2/creeps-1.html,creeps,DMG-MQ-USA-1,20B,DMG-DECN-10,K,6,Mar/1998,March/1998,1998,3,N-4001EJGW,DMG-MQE-2 E1 N-4001EJGW-J08 9822E7026,,,22/1998,Week 22/1998,1998,,22,MBC1B1,DMG MBC1B1 Nintendo S 9816 5 A,Sharp,Sharp,16/1998,Week 16/1998,1998,,16,BR6265BF-10SL,BR6265BF-10SL 814 136N,ROHM,ROHM,14/1998,Week 14/1998,1998,,14,MM1026A,746 26A,Mitsumi,Mitsumi,1997,1997,1997,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
|
|
|
|
@ -1,20 +1,30 @@
|
|||
package be.kuleuven.howlongtobeat
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import java.net.URL
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun Uri.toBitmap(activity: Activity): Bitmap
|
||||
= BitmapFactory.decodeStream(activity.contentResolver.openInputStream(this))
|
||||
fun Fragment.requireContentResolver(): ContentResolver = requireContext().contentResolver
|
||||
|
||||
fun Uri.toBitmap(contentResolver: ContentResolver): Bitmap
|
||||
= BitmapFactory.decodeStream(contentResolver.openInputStream(this))
|
||||
|
||||
fun URL.downloadAsImage(): Bitmap =
|
||||
BitmapFactory.decodeStream(this.openConnection().getInputStream())
|
||||
|
||||
fun Bitmap.save(location: String, context: Context) {
|
||||
context.openFileOutput(location, Context.MODE_PRIVATE).use {
|
||||
compress(Bitmap.CompressFormat.JPEG, 85, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun Bitmap.scaleToWidth(width: Int): Bitmap {
|
||||
val aspectRatio = this.width.toFloat() / this.height.toFloat()
|
||||
val height = (width / aspectRatio).roundToInt()
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
package be.kuleuven.howlongtobeat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import be.kuleuven.howlongtobeat.databinding.FragmentGamedetailBinding
|
||||
import be.kuleuven.howlongtobeat.model.Game
|
||||
import be.kuleuven.howlongtobeat.model.GameRepository
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
||||
class GameDetailFragment : Fragment(R.layout.fragment_gamedetail) {
|
||||
|
||||
private lateinit var binding: FragmentGamedetailBinding
|
||||
private lateinit var gameRepo: GameRepository
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
binding = FragmentGamedetailBinding.inflate(layoutInflater)
|
||||
|
||||
gameRepo = GameRepository.defaultImpl(requireContext())
|
||||
val gameId = arguments?.getSerializable(Game.GAME_ID).toString().toInt()
|
||||
val game = gameRepo.find(gameId)
|
||||
|
||||
binding.txtDetailTitle.text = "${game.title}\n${game.cartCode}"
|
||||
binding.txtDetailGameStats.text = "${game.howLongToBeat} hr(s) to beat"
|
||||
binding.chkDetailFinished.isChecked = game.finished
|
||||
binding.chkDetailFinished.setOnClickListener {
|
||||
game.finished = binding.chkDetailFinished.isChecked
|
||||
gameRepo.update(game)
|
||||
|
||||
Snackbar.make(binding.root, "Saved!", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
requireContext().openFileInput(game.boxartFileName).use {
|
||||
binding.imgDetailBoxArt.setImageBitmap(BitmapFactory.decodeStream(it))
|
||||
}
|
||||
requireContext().openFileInput(game.snapshotFileName).use {
|
||||
binding.imgDetailSnapshot.setImageBitmap(BitmapFactory.decodeStream(it))
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
}
|
|
@ -5,27 +5,36 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.findFragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import be.kuleuven.howlongtobeat.model.Game
|
||||
|
||||
class GameListAdapter(val items: List<Game>) : RecyclerView.Adapter<GameListAdapter.GameListViewHolder>() {
|
||||
|
||||
private lateinit var parentFragment: GameListFragment
|
||||
|
||||
inner class GameListViewHolder(currentItemView: View) : RecyclerView.ViewHolder(currentItemView)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameListViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_game, parent, false)
|
||||
parentFragment = parent.findFragment()
|
||||
return GameListViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: GameListViewHolder, position: Int) {
|
||||
val currentTodoItem = items[position]
|
||||
val game = items[position]
|
||||
holder.itemView.apply {
|
||||
val checkBoxTodo = findViewById<CheckBox>(R.id.chkTodoDone)
|
||||
findViewById<TextView>(R.id.txtTodoTitle).text = currentTodoItem.title
|
||||
setOnLongClickListener {
|
||||
parentFragment.selectGame(game)
|
||||
true
|
||||
}
|
||||
|
||||
checkBoxTodo.isChecked = currentTodoItem.isDone
|
||||
checkBoxTodo.setOnClickListener {
|
||||
currentTodoItem.isDone = checkBoxTodo.isChecked
|
||||
val chkFinished = findViewById<CheckBox>(R.id.chkGameFinished)
|
||||
findViewById<TextView>(R.id.txtTodoTitle).text = game.title
|
||||
|
||||
chkFinished.isChecked = game.finished
|
||||
chkFinished.setOnClickListener {
|
||||
game.finished = chkFinished.isChecked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
@ -41,32 +42,22 @@ class GameListFragment : Fragment(R.layout.fragment_gamelist) {
|
|||
}
|
||||
|
||||
private fun loadGames() {
|
||||
gameList.clear()
|
||||
gameList.addAll(gameRepository.load())
|
||||
if(!gameList.any()) {
|
||||
gameList.add(Game.NONE_YET)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
fun onHltbGamesRetrieved(games: List<HowLongToBeatResult>) {
|
||||
gameList.clear()
|
||||
gameList.addAll(games.map { Game("${it.title} (${it.howlong})", false) })
|
||||
adapter.notifyDataSetChanged()
|
||||
fun selectGame(game: Game) {
|
||||
findNavController().navigate(R.id.action_gameListFragment_to_gameDetailFragment, bundleOf(Game.GAME_ID to game.id.toString()))
|
||||
}
|
||||
|
||||
fun clearAllItems() {
|
||||
gameList.clear()
|
||||
gameList.add(Game.NONE_YET)
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun clearLatestItem() {
|
||||
if(gameList.size >= 1) {
|
||||
gameList.removeAt(gameList.size - 1)
|
||||
adapter.notifyItemRemoved(gameList.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package be.kuleuven.howlongtobeat
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -31,8 +31,11 @@ class HltbResultsAdapter(private val items: List<HowLongToBeatResult>) : Recycle
|
|||
val itm = items[position]
|
||||
|
||||
holder.itemView.apply {
|
||||
setOnClickListener {
|
||||
parentFragment.addResultToGameLibrary(itm)
|
||||
var art = BitmapFactory.decodeResource(resources, R.drawable.emptygb)
|
||||
|
||||
setOnLongClickListener {
|
||||
parentFragment.addResultToGameLibrary(itm, art)
|
||||
true
|
||||
}
|
||||
|
||||
findViewById<TextView>(R.id.txtHltbItemResult).text = itm.toString()
|
||||
|
@ -40,7 +43,6 @@ class HltbResultsAdapter(private val items: List<HowLongToBeatResult>) : Recycle
|
|||
|
||||
if(itm.hasBoxart()) {
|
||||
MainScope().launch{
|
||||
var art: Bitmap? = null
|
||||
withContext(Dispatchers.IO) {
|
||||
art = itm.boxartUrl().downloadAsImage()
|
||||
}
|
||||
|
@ -49,7 +51,7 @@ class HltbResultsAdapter(private val items: List<HowLongToBeatResult>) : Recycle
|
|||
}
|
||||
}
|
||||
} else {
|
||||
boxArtView.visibility = View.INVISIBLE
|
||||
boxArtView.setImageBitmap(art)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
package be.kuleuven.howlongtobeat
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
@ -12,8 +15,10 @@ import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
|
|||
import be.kuleuven.howlongtobeat.model.Game
|
||||
import be.kuleuven.howlongtobeat.model.GameRepository
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import java.io.FileInputStream
|
||||
|
||||
class HltbResultsFragment : Fragment(R.layout.fragment_hltbresults) {
|
||||
private lateinit var cachedSnapshotPath: Uri
|
||||
private lateinit var binding: FragmentHltbresultsBinding
|
||||
private lateinit var adapter: HltbResultsAdapter
|
||||
private lateinit var gameRepository: GameRepository
|
||||
|
@ -26,6 +31,7 @@ class HltbResultsFragment : Fragment(R.layout.fragment_hltbresults) {
|
|||
binding = FragmentHltbresultsBinding.inflate(layoutInflater)
|
||||
|
||||
val resultFromLoadingFragment = arguments?.getSerializable(HowLongToBeatResult.RESULT) as List<HowLongToBeatResult>
|
||||
cachedSnapshotPath = Uri.parse(arguments?.getSerializable(HowLongToBeatResult.SNAPSHOT_URI) as String)
|
||||
|
||||
gameRepository = GameRepository.defaultImpl(requireContext())
|
||||
|
||||
|
@ -36,9 +42,22 @@ class HltbResultsFragment : Fragment(R.layout.fragment_hltbresults) {
|
|||
return binding.root
|
||||
}
|
||||
|
||||
fun addResultToGameLibrary(hltbResult: HowLongToBeatResult) {
|
||||
gameRepository.save(Game(hltbResult))
|
||||
fun addResultToGameLibrary(hltbResult: HowLongToBeatResult, downloadedBoxart: Bitmap) {
|
||||
val game = Game(hltbResult)
|
||||
downloadedBoxart.save(game.boxartFileName, requireContext())
|
||||
copyCachedSnapshotTo(game.snapshotFileName)
|
||||
gameRepository.save(game)
|
||||
|
||||
Snackbar.make(requireView(), "Added ${hltbResult.title} to library!", Snackbar.LENGTH_LONG).show()
|
||||
findNavController().navigate(R.id.action_hltbResultsFragment_to_gameListFragment)
|
||||
}
|
||||
|
||||
private fun copyCachedSnapshotTo(destination: String) {
|
||||
val inStream = requireContext().contentResolver.openInputStream(cachedSnapshotPath) as FileInputStream
|
||||
inStream.use {
|
||||
requireContext().openFileOutput(destination, Context.MODE_PRIVATE).use { outStream ->
|
||||
inStream.channel.transferTo(0, inStream.channel.size(), outStream.channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,8 +14,10 @@ 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.Cartridge
|
||||
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
|
||||
|
@ -28,7 +30,7 @@ import java.io.File
|
|||
class LoadingFragment : Fragment(R.layout.fragment_loading) {
|
||||
|
||||
private lateinit var hltbClient: HLTBClient
|
||||
private lateinit var cartRepo: CartridgesRepository
|
||||
private lateinit var cartRepos: List<CartridgesRepository>
|
||||
private lateinit var imageRecognizer: ImageRecognizer
|
||||
|
||||
private lateinit var cameraPermissionActivityResult: ActivityResultLauncher<String>
|
||||
|
@ -46,7 +48,11 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
|
|||
binding = FragmentLoadingBinding.inflate(layoutInflater)
|
||||
main = activity as MainActivity
|
||||
|
||||
cartRepo = CartridgesRepository.fromAsset(main.applicationContext)
|
||||
// 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)
|
||||
|
||||
|
@ -55,11 +61,27 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
|
|||
binding.btnRetryAfterLoading.setOnClickListener {
|
||||
tryToMakeCameraSnap()
|
||||
}
|
||||
tryToMakeCameraSnap()
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
val inProgress = savedInstanceState?.getBoolean("inprogress") ?: false
|
||||
if(!inProgress) {
|
||||
// Don't do this in onCreateView, things go awry if you rotate the smartphone!
|
||||
tryToMakeCameraSnap()
|
||||
}
|
||||
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.run {
|
||||
putBoolean("inprogress", snapshot != null)
|
||||
}
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun cameraSnapTaken(succeeded: Boolean) {
|
||||
if(!succeeded || snapshot == null) {
|
||||
errorInProgress("Photo could not be saved, try again?")
|
||||
|
@ -67,42 +89,40 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
|
|||
}
|
||||
|
||||
progress("Scaling image for upload...")
|
||||
val bitmap = snapshot!!.toBitmap(main).scaleToWidth(1600)
|
||||
val bitmap = snapshot!!.toBitmap(requireContentResolver()).scaleToWidth(1600)
|
||||
|
||||
MainScope().launch{
|
||||
findGameBasedOnCameraSnap(bitmap)
|
||||
try {
|
||||
findGameBasedOnCameraSnap(bitmap)
|
||||
} catch (errorDuringFind: UnableToFindGameException) {
|
||||
errorInProgress("${errorDuringFind.message}\nRetry?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun findGameBasedOnCameraSnap(pic: Bitmap) {
|
||||
/*
|
||||
var picToAnalyze = pic
|
||||
// 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(pic)
|
||||
val cartCode = imageRecognizer.recognizeCartCode(picToAnalyze)
|
||||
?: throw UnableToFindGameException("No cart code in your pic found")
|
||||
|
||||
if (cartCode == null) {
|
||||
errorInProgress("No cart code in your pic found. Retry?")
|
||||
return
|
||||
}
|
||||
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("Found cart code $cartCode, looking in DB...")
|
||||
val foundCart = cartRepo.find(cartCode)
|
||||
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}")
|
||||
|
||||
if (foundCart == Cartridge.UNKNOWN_CART) {
|
||||
errorInProgress("$cartCode is an unknown game cart. Retry?")
|
||||
return
|
||||
}
|
||||
|
||||
progress("Valid cart code $cartCode, looking in HLTB...")
|
||||
|
||||
*/
|
||||
val foundCart = Cartridge("DMG", "Super Mario Land", "DMG-something")
|
||||
hltbClient.find(foundCart.title) {
|
||||
Snackbar.make(requireView(), "Found ${it.size} game(s) for cart ${foundCart.code}", Snackbar.LENGTH_LONG).show()
|
||||
|
||||
// TODO wat als geen hltb results gevonden?
|
||||
val bundle = bundleOf(HowLongToBeatResult.RESULT to it)
|
||||
findNavController().navigate(R.id.action_loadingFragment_to_hltbResultsFragment, bundle)
|
||||
}
|
||||
Snackbar.make(requireView(), "Found ${hltbResults.size} game(s) for cart ${foundCart.code}", Snackbar.LENGTH_LONG).show()
|
||||
val bundle = bundleOf(
|
||||
HowLongToBeatResult.RESULT to hltbResults,
|
||||
HowLongToBeatResult.SNAPSHOT_URI to snapshot.toString()
|
||||
)
|
||||
findNavController().navigate(R.id.action_loadingFragment_to_hltbResultsFragment, bundle)
|
||||
}
|
||||
|
||||
private fun cameraPermissionAcceptedOrDenied(succeeded: Boolean) {
|
||||
|
@ -130,6 +150,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
|
|||
createNewFile()
|
||||
deleteOnExit()
|
||||
}
|
||||
|
||||
snapshot = FileProvider.getUriForFile(main.applicationContext, "${BuildConfig.APPLICATION_ID}.provider", tempFile)
|
||||
}
|
||||
|
||||
|
@ -144,6 +165,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
|
|||
}
|
||||
|
||||
private fun errorInProgress(msg: String) {
|
||||
snapshot = null
|
||||
progress(msg)
|
||||
binding.indeterminateBar.visibility = View.GONE
|
||||
Snackbar.make(requireView(), msg, Snackbar.LENGTH_LONG).show()
|
||||
|
|
|
@ -1,21 +1,32 @@
|
|||
package be.kuleuven.howlongtobeat
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import be.kuleuven.howlongtobeat.databinding.ActivityMainBinding
|
||||
import be.kuleuven.howlongtobeat.model.GameRepository
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var menuBarToggle: ActionBarDrawerToggle
|
||||
private lateinit var gameRepository: GameRepository
|
||||
|
||||
private val navHostFragment: NavHostFragment
|
||||
get() = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
|
||||
gameRepository = GameRepository.defaultImpl(applicationContext)
|
||||
setupMenuDrawer()
|
||||
|
||||
setContentView(binding.root)
|
||||
|
@ -30,24 +41,56 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
binding.navView.setNavigationItemSelectedListener {
|
||||
when (it.itemId) {
|
||||
R.id.mnuClear -> clearAllItems()
|
||||
R.id.mnuClearLatest -> clearLatestItem()
|
||||
R.id.mnuReset -> resetItems()
|
||||
R.id.mnuClear -> tryToClearAllItems()
|
||||
R.id.mnuStats -> showStats()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearAllItems() {
|
||||
//todoFragment.clearAllItems()
|
||||
gameRepository.overwrite(listOf())
|
||||
val currentActiveFragment = navHostFragment.childFragmentManager.fragments[0] as GameListFragment
|
||||
currentActiveFragment.clearAllItems()
|
||||
binding.drawerLayout.closeDrawer(GravityCompat.START)
|
||||
}
|
||||
|
||||
private fun clearLatestItem() {
|
||||
//todoFragment.clearLatestItem()
|
||||
private fun tryToClearAllItems() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Delete all games from the DB")
|
||||
.setMessage("Are you sure?")
|
||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setPositiveButton("Yup") { dialog, _ ->
|
||||
clearAllItems()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton("Nah") { dialog, _ ->
|
||||
close(dialog)
|
||||
}
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun resetItems() {
|
||||
//todoFragment.resetItems()
|
||||
private fun close(dialog: DialogInterface) {
|
||||
dialog.dismiss()
|
||||
binding.drawerLayout.closeDrawer(GravityCompat.START)
|
||||
}
|
||||
|
||||
private fun showStats() {
|
||||
val allGames = gameRepository.load()
|
||||
val hoursToBeat = allGames.filter { !it.finished }.map { it.howLongToBeat }.sum()
|
||||
val hoursAlreadyBeat = allGames.filter { it.finished }.map { it.howLongToBeat }.sum()
|
||||
val percCompleted = if(hoursAlreadyBeat > 0) ((hoursToBeat / hoursAlreadyBeat) * 100).roundToInt() else 0
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Game library stats")
|
||||
.setMessage("Total games: ${allGames.size}\nHours still to beat: ${hoursToBeat}\nHours already beat: ${hoursAlreadyBeat}\n\n$percCompleted% total completed.")
|
||||
.setIcon(android.R.drawable.ic_dialog_info)
|
||||
.setNeutralButton("Nice!") { dialog, _ ->
|
||||
close(dialog)
|
||||
}
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
package be.kuleuven.howlongtobeat
|
||||
|
||||
class UnableToFindGameException(message: String?) : Throwable(message)
|
|
@ -5,8 +5,11 @@ data class Cartridge(val type: String, val name: String, val code: String) {
|
|||
companion object {
|
||||
val bracketRe = """\(.+\)""".toRegex()
|
||||
val UNKNOWN_CART = Cartridge("", "UNKNOWN CART", "DMG-???")
|
||||
val KNOWN_CART_PREFIXES = listOf("DMG", "CGB")
|
||||
|
||||
fun isValid(code: String): Boolean = code.startsWith("DMG-") || code.startsWith("CGB-")
|
||||
fun isValid(code: String): Boolean =
|
||||
code != "" && !code.contains("\n") &&
|
||||
KNOWN_CART_PREFIXES.any { code.startsWith("$it-") }
|
||||
}
|
||||
|
||||
val title = bracketRe.replace(name, "").replace("-", "").trim()
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
package be.kuleuven.howlongtobeat.cartridges
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.Response
|
||||
import com.android.volley.toolbox.StringRequest
|
||||
import com.android.volley.toolbox.Volley
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class CartridgeFinderViaDuckDuckGo(private val context: Context) : CartridgesRepository {
|
||||
|
||||
class CartridgeFinderViaDuckDuckGoRequest(query: String, responseListener: Response.Listener<String>) :
|
||||
StringRequest(
|
||||
Method.GET, "https://html.duckduckgo.com/html/?q=${query}", responseListener,
|
||||
Response.ErrorListener {
|
||||
println("Something went wrong: ${it.message}")
|
||||
}) {
|
||||
|
||||
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(code: String?): Cartridge? = suspendCoroutine { cont ->
|
||||
if(code == null) {
|
||||
cont.resumeWith(Result.success(null))
|
||||
} else {
|
||||
val queue = Volley.newRequestQueue(context)
|
||||
queue.add(CartridgeFinderViaDuckDuckGoRequest(code) { html ->
|
||||
cont.resumeWith(Result.success(DuckDuckGoResultParser.parse(html, code)))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,38 +1,13 @@
|
|||
package be.kuleuven.howlongtobeat.cartridges
|
||||
|
||||
import android.content.Context
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Reads cartridges.csv export from https://gbhwdb.gekkio.fi/cartridges/
|
||||
* Headers: type,name,title,slug,url,contributor,code,...
|
||||
* E.g.: DMG-MQE-2,"Super Mario Land 2 - 6 Golden Coins (USA, Europe) (Rev 2)",Entry #1,creeps-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-2/creeps-1.html,creeps,DMG-MQ-USA-1,
|
||||
*/
|
||||
class CartridgesRepository(csvStream: InputStream) {
|
||||
|
||||
companion object {
|
||||
fun fromAsset(context: Context): CartridgesRepository =
|
||||
CartridgesRepository(context.assets.open("cartridges.csv"))
|
||||
}
|
||||
|
||||
val cartridges: List<Cartridge> =
|
||||
csvStream.bufferedReader().use {
|
||||
it.readLines()
|
||||
}.filter {
|
||||
!it.startsWith("type,name,title,")
|
||||
}.map {
|
||||
val data = it.split(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)".toRegex())
|
||||
Cartridge(data[0].replace("\"", "").trim(), data[1].replace("\"", "").trim(), data[6].replace("\"", "").trim())
|
||||
}.filter { it.code != "" }
|
||||
.toList()
|
||||
|
||||
fun find(code: String?): Cartridge {
|
||||
if(code == null) return Cartridge.UNKNOWN_CART
|
||||
val possiblyFound = cartridges.filter {
|
||||
it.code == code || it.code.contains(code) || code.contains(it.code)
|
||||
}.firstOrNull()
|
||||
|
||||
return possiblyFound ?: Cartridge.UNKNOWN_CART
|
||||
}
|
||||
|
||||
interface CartridgesRepository {
|
||||
suspend fun find(code: String?): Cartridge?
|
||||
}
|
||||
|
||||
suspend fun findFirstCartridgeForRepos(code: String?, repos: List<CartridgesRepository>): Cartridge? {
|
||||
for(repo in repos) {
|
||||
val result = repo.find(code)
|
||||
if(result != null) return result
|
||||
}
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package be.kuleuven.howlongtobeat.cartridges
|
||||
|
||||
import android.content.Context
|
||||
import java.io.InputStream
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Reads cartridges.csv export from https://gbhwdb.gekkio.fi/cartridges/
|
||||
* Headers: type,name,title,slug,url,contributor,code,...
|
||||
* E.g.: DMG-MQE-2,"Super Mario Land 2 - 6 Golden Coins (USA, Europe) (Rev 2)",Entry #1,creeps-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-2/creeps-1.html,creeps,DMG-MQ-USA-1,
|
||||
*/
|
||||
class CartridgesRepositoryGekkioFi(csvStream: InputStream) : CartridgesRepository {
|
||||
|
||||
companion object {
|
||||
fun fromAsset(context: Context): CartridgesRepositoryGekkioFi =
|
||||
CartridgesRepositoryGekkioFi(context.assets.open("cartridges.csv"))
|
||||
}
|
||||
|
||||
val cartridges: List<Cartridge> =
|
||||
csvStream.bufferedReader().use {
|
||||
it.readLines()
|
||||
}.filter {
|
||||
!it.startsWith("type,name,title,")
|
||||
}.map {
|
||||
val data = it.split(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)".toRegex())
|
||||
Cartridge(data[0].replace("\"", "").trim(), data[1].replace("\"", "").trim(), data[6].replace("\"", "").trim())
|
||||
}.filter { it.code != "" }
|
||||
.toList()
|
||||
|
||||
override suspend fun find(code: String?): Cartridge? = suspendCoroutine { cont ->
|
||||
if(code == null) {
|
||||
cont.resumeWith(Result.success(null))
|
||||
} else {
|
||||
val possiblyFound = cartridges.filter {
|
||||
it.code == code || it.code.contains(code) || code.contains(it.code)
|
||||
}.firstOrNull()
|
||||
cont.resumeWith(Result.success(possiblyFound))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package be.kuleuven.howlongtobeat.cartridges
|
||||
|
||||
object DuckDuckGoResultParser {
|
||||
|
||||
private val resultMatcher = """<a rel=".+" class="result__a" href=".+">(.+)</a>""".toRegex()
|
||||
private val specialCharsToRemove = listOf(
|
||||
"|",
|
||||
".",
|
||||
"-",
|
||||
"/",
|
||||
",",
|
||||
"!",
|
||||
"Get information and compare prices of",
|
||||
"for Game Boy",
|
||||
"Release Information",
|
||||
"Nintendo",
|
||||
"Game Boy Advance",
|
||||
"Game Boy color",
|
||||
"Game Boy",
|
||||
"GameBoy",
|
||||
"Game ",
|
||||
"GBC",
|
||||
"GBA",
|
||||
"VGDb",
|
||||
"ebay"
|
||||
)
|
||||
|
||||
fun parse(html: String, fromCode: String): Cartridge? {
|
||||
// There are bound to be multiple results. Just fetch the first one as an educated guess
|
||||
val matched = resultMatcher.find(html) ?: return null
|
||||
|
||||
var match = matched.groupValues[1]
|
||||
.lowercase()
|
||||
.replace(fromCode.lowercase(), "")
|
||||
|
||||
specialCharsToRemove.forEach {
|
||||
match = match.replace(it.lowercase(), "")
|
||||
}
|
||||
return Cartridge("Unknown", match.trim(), fromCode)
|
||||
}
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
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) {
|
||||
|
||||
|
@ -41,10 +43,11 @@ class HLTBClient(val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun find(query: String, onResponseFetched: (List<HowLongToBeatResult>) -> Unit) {
|
||||
suspend fun find(cart: Cartridge): List<HowLongToBeatResult>? = suspendCoroutine { cont ->
|
||||
val queue = Volley.newRequestQueue(context)
|
||||
val req = HLTBRequest(query) {
|
||||
onResponseFetched(HowLongToBeatResultParser.parse(it))
|
||||
val req = HLTBRequest(cart.title) {
|
||||
val hltbResults = HowLongToBeatResultParser.parse(it, cart)
|
||||
cont.resumeWith(Result.success(hltbResults))
|
||||
}
|
||||
queue.add(req)
|
||||
}
|
||||
|
|
|
@ -4,12 +4,13 @@ import kotlinx.serialization.Serializable
|
|||
import java.net.URL
|
||||
|
||||
@Serializable
|
||||
data class HowLongToBeatResult(val title: String, val howlong: Double, val boxart: String = "") : java.io.Serializable {
|
||||
data class HowLongToBeatResult(val title: String, val cartCode: String, val howlong: Double, val boxartUrl: String = "") : java.io.Serializable {
|
||||
companion object {
|
||||
const val RESULT = "HowLongToBeatResult"
|
||||
const val SNAPSHOT_URI = "SnapshotUri"
|
||||
}
|
||||
|
||||
fun hasBoxart(): Boolean = boxart.startsWith(HLTBClient.DOMAIN)
|
||||
fun boxartUrl(): URL = URL(boxart)
|
||||
fun hasBoxart(): Boolean = boxartUrl.startsWith(HLTBClient.DOMAIN)
|
||||
fun boxartUrl(): URL = URL(boxartUrl)
|
||||
override fun toString(): String = "$title ($howlong hrs)"
|
||||
}
|
|
@ -1,12 +1,14 @@
|
|||
package be.kuleuven.howlongtobeat.hltb
|
||||
|
||||
import be.kuleuven.howlongtobeat.cartridges.Cartridge
|
||||
|
||||
object HowLongToBeatResultParser {
|
||||
|
||||
private val titleMatcher = """<a class=".+" title="(.+)" href=""".toRegex()
|
||||
private val hourMatcher = """<div class=".+">(.+) Hours""".toRegex()
|
||||
private val boxArtMatcher = """<img alt=".+" src="(.+)"""".toRegex()
|
||||
|
||||
fun parse(html: String): List<HowLongToBeatResult> {
|
||||
fun parse(html: String, sourceCart: Cartridge): List<HowLongToBeatResult>? {
|
||||
val result = arrayListOf<HowLongToBeatResult>()
|
||||
val rows = html.split("\n")
|
||||
for(i in 0..rows.size - 1) {
|
||||
|
@ -16,11 +18,11 @@ object HowLongToBeatResultParser {
|
|||
val hour = parseHoursFromRow(i, rows)
|
||||
val boxart = parseBoxArtFromRow(i, rows)
|
||||
|
||||
result.add(HowLongToBeatResult(title, hour, boxart))
|
||||
result.add(HowLongToBeatResult(title, sourceCart.code, hour, boxart))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return if(result.any()) result else null
|
||||
}
|
||||
|
||||
private fun parseBoxArtFromRow(row: Int, rows: List<String>): String {
|
||||
|
|
|
@ -8,19 +8,25 @@ import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
|
|||
@Entity
|
||||
data class Game(
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "is_done") var isDone: Boolean = false,
|
||||
@ColumnInfo(name = "code") val cartCode: String,
|
||||
@ColumnInfo(name = "hltb") val howLongToBeat: Double,
|
||||
@ColumnInfo(name = "finished") var finished: Boolean = false,
|
||||
@PrimaryKey(autoGenerate = true) var id: Int = 0) : java.io.Serializable {
|
||||
|
||||
constructor(result: HowLongToBeatResult) : this(result.title)
|
||||
constructor(result: HowLongToBeatResult) : this(result.title, result.cartCode, result.howlong)
|
||||
|
||||
// TODO more columns (platform? hours, paths of images?)
|
||||
val boxartFileName
|
||||
get() = "box-${cartCode}.jpg"
|
||||
val snapshotFileName
|
||||
get() = "snap-${cartCode}.jpg"
|
||||
|
||||
fun check() {
|
||||
isDone = true
|
||||
fun finish() {
|
||||
finished = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
val NONE_YET = Game("No entries yet, add one!")
|
||||
val NONE_YET = Game("No entries yet, add one!", "", 0.0)
|
||||
val GAME_ID = "GameId"
|
||||
}
|
||||
|
||||
}
|
|
@ -15,6 +15,10 @@ interface GameRepository {
|
|||
|
||||
fun load(): List<Game>
|
||||
|
||||
fun update(game: Game)
|
||||
|
||||
fun find(id: Int): Game
|
||||
|
||||
fun save(game: Game)
|
||||
|
||||
fun overwrite(items: List<Game>)
|
||||
|
|
|
@ -19,6 +19,9 @@ class GameRepositoryRoomImpl(appContext: Context) :
|
|||
}
|
||||
|
||||
override fun load(): List<Game> = dao.query()
|
||||
override fun update(game: Game) = dao.update(listOf(game))
|
||||
|
||||
override fun find(id: Int): Game = load().single { it.id == id }
|
||||
|
||||
override fun save(game: Game) {
|
||||
db.runInTransaction {
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
After Width: | Height: | Size: 220 KiB |
Binary file not shown.
Before Width: | Height: | Size: 672 KiB |
|
@ -0,0 +1,75 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgDetailBoxArt"
|
||||
android:layout_width="279dp"
|
||||
android:layout_height="224dp"
|
||||
android:layout_marginTop="36dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/txtDetailTitle"
|
||||
app:srcCompat="@drawable/emptygb" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgDetailSnapshot"
|
||||
android:layout_width="213dp"
|
||||
android:layout_height="194dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/txtDetailGameStats"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imgDetailBoxArt"
|
||||
app:layout_constraintVertical_bias="0.413"
|
||||
app:srcCompat="@drawable/emptygb" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtDetailTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Game Title"
|
||||
android:textAlignment="center"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.498"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtDetailGameStats"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:text="stats"
|
||||
android:textSize="24sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/txtDetailFinishedLabel"
|
||||
app:layout_constraintHorizontal_bias="0.057"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/chkDetailFinished"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtDetailFinishedLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:text="Finished:"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/chkDetailFinished"
|
||||
app:layout_constraintHorizontal_bias="0.879"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -13,12 +13,12 @@
|
|||
android:text="Some Title"
|
||||
android:textSize="24sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/chkTodoDone"
|
||||
app:layout_constraintEnd_toStartOf="@+id/chkGameFinished"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/chkTodoDone"
|
||||
android:id="@+id/chkGameFinished"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="100dp"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:longClickable="true"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
|
|
|
@ -1,7 +1,32 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="150dp"
|
||||
android:background="@color/design_default_color_primary">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
android:layout_width="179dp"
|
||||
android:layout_height="152dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:translationX="10dp"
|
||||
android:translationY="-20dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/sml2" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:text="HLTB App"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="24sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -2,12 +2,9 @@
|
|||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/mnuClear"
|
||||
android:title="Clear All Items" />
|
||||
android:title="Remove All Games" />
|
||||
<item
|
||||
android:id="@+id/mnuClearLatest"
|
||||
android:title="Clear Latest Item" />
|
||||
<item
|
||||
android:id="@+id/mnuReset"
|
||||
android:title="Reset to Default Items" />
|
||||
android:id="@+id/mnuStats"
|
||||
android:title="Statistics" />
|
||||
|
||||
</menu>
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
<action
|
||||
android:id="@+id/action_gameListFragment_to_loadingFragment"
|
||||
app:destination="@id/loadingFragment" />
|
||||
<action
|
||||
android:id="@+id/action_gameListFragment_to_gameDetailFragment"
|
||||
app:destination="@id/gameDetailFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/loadingFragment"
|
||||
|
@ -28,4 +31,8 @@
|
|||
android:id="@+id/action_hltbResultsFragment_to_gameListFragment"
|
||||
app:destination="@id/gameListFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/gameDetailFragment"
|
||||
android:name="be.kuleuven.howlongtobeat.GameDetailFragment"
|
||||
android:label="GameDetailFragment" />
|
||||
</navigation>
|
|
@ -1,10 +1,28 @@
|
|||
package be.kuleuven.howlongtobeat.cartridges
|
||||
|
||||
import junit.framework.Assert.assertEquals
|
||||
|
||||
import junit.framework.TestCase.*
|
||||
import org.junit.Test
|
||||
|
||||
class CartridgeTest {
|
||||
|
||||
@Test
|
||||
fun isValid_OnlyCartridgeCodeItself_IsValid() {
|
||||
assertTrue(Cartridge.isValid("DMG-MQ-EUR"))
|
||||
assertTrue(Cartridge.isValid("DMG-MQ-USA-1"))
|
||||
assertTrue(Cartridge.isValid("CGB-ABC-DEF-WHATEVER"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isValid_Empty_IsNotValid() {
|
||||
assertFalse(Cartridge.isValid(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isValid_CatridgeCodeWithOtherJunkOnNewLine_IsNotValid() {
|
||||
assertFalse(Cartridge.isValid("DMG-MQ-EUR\nMADE IN JAPAN"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun titleReplacesIrrelevantDetailsFromName() {
|
||||
val cart = Cartridge("type", "name (irrelevant details please remove thxxx)", "DMG-whatever")
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
package be.kuleuven.howlongtobeat.car
|
||||
|
||||
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepositoryGekkioFi
|
||||
import junit.framework.Assert.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
|
||||
class CartridgesRepositoryGekkioFiTest {
|
||||
|
||||
private lateinit var repo: CartridgesRepositoryGekkioFi
|
||||
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
repo = javaClass.getResource("/cartridges.csv").openStream().use {
|
||||
CartridgesRepositoryGekkioFi(it)
|
||||
}
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
dispatcher.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSingleCodeShouldBeEmpty() {
|
||||
repo.cartridges.forEach {
|
||||
assertTrue("Code of ${it.title} should be filled in but is '${it.code}'", it.code.length > 4)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findReturnsCartridgeObjectOfCode() = runBlocking {
|
||||
launch(Dispatchers.Main) {
|
||||
val smbDeluxe = repo.find("CGB-AHYE-USA")
|
||||
assertTrue(smbDeluxe!!.name.contains("Super Mario Bros. Deluxe"))
|
||||
}
|
||||
println("done")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsWholeCsvFileAsListOfCartridges() {
|
||||
assertFalse(repo.cartridges.isEmpty())
|
||||
val smbDeluxe = repo.cartridges.find {
|
||||
it.code == "CGB-AHYE-USA"
|
||||
}
|
||||
assertTrue(smbDeluxe != null)
|
||||
assertTrue(smbDeluxe!!.name.contains("Super Mario Bros. Deluxe"))
|
||||
assertEquals("Super Mario Bros. Deluxe", smbDeluxe.title)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
package be.kuleuven.howlongtobeat.car
|
||||
|
||||
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository
|
||||
import junit.framework.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
|
||||
|
||||
class CartridgesRepositoryTest {
|
||||
|
||||
private lateinit var repo: CartridgesRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
repo = javaClass.getResource("/cartridges.csv").openStream().use {
|
||||
CartridgesRepository(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSingleCodeShouldBeEmpty() {
|
||||
repo.cartridges.forEach {
|
||||
assertTrue("Code of ${it.title} should be filled in but is '${it.code}'", it.code.length > 4)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsWholeCsvFileAsListOfCartridges() {
|
||||
assertFalse(repo.cartridges.isEmpty())
|
||||
val smbDeluxe = repo.cartridges.find {
|
||||
it.code == "CGB-AHYE-USA"
|
||||
}
|
||||
assertTrue(smbDeluxe != null)
|
||||
assertTrue(smbDeluxe!!.name.contains("Super Mario Bros. Deluxe"))
|
||||
assertEquals("Super Mario Bros. Deluxe", smbDeluxe.title)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,994 @@
|
|||
package be.kuleuven.howlongtobeat.cartridges
|
||||
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import junit.framework.TestCase.assertNull as assertNull1
|
||||
|
||||
class DuckDuckGoResultParserTest {
|
||||
|
||||
@Test
|
||||
fun parse_marioGolfCodeSample() {
|
||||
val html = """
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
|
||||
<!--[if IE 6]><html class="ie6" xmlns="http://www.w3.org/1999/xhtml"><![endif]-->
|
||||
<!--[if IE 7]><html class="lt-ie8 lt-ie9" xmlns="http://www.w3.org/1999/xhtml"><![endif]-->
|
||||
<!--[if IE 8]><html class="lt-ie9" xmlns="http://www.w3.org/1999/xhtml"><![endif]-->
|
||||
<!--[if gt IE 8]><!--><html xmlns="http://www.w3.org/1999/xhtml"><!--<![endif]-->
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=1" />
|
||||
<meta name="referrer" content="origin" />
|
||||
<meta name="HandheldFriendly" content="true" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>cgb-awxp-eur-1 at DuckDuckGo</title>
|
||||
<link title="DuckDuckGo (HTML)" type="application/opensearchdescription+xml" rel="search" href="//duckduckgo.com/opensearch_html_v2.xml" />
|
||||
<link href="//duckduckgo.com/favicon.ico" rel="shortcut icon" />
|
||||
<link rel="icon" href="//duckduckgo.com/favicon.ico" type="image/x-icon" />
|
||||
<link rel="apple-touch-icon" href="//duckduckgo.com/assets/logo_icon128.v101.png"/>
|
||||
<link rel="image_src" href="//duckduckgo.com/assets/logo_homepage.normal.v101.png"/>
|
||||
<link type="text/css" media="handheld, all" href="//duckduckgo.com/h1997.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="body--html">
|
||||
<a name="top" id="top"></a>
|
||||
|
||||
<form action="/html/" method="post">
|
||||
<input type="text" name="state_hidden" id="state_hidden" />
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<div class="site-wrapper-border"></div>
|
||||
|
||||
<div id="header" class="header cw header--html">
|
||||
<a title="DuckDuckGo" href="/html/" class="header__logo-wrap"></a>
|
||||
|
||||
|
||||
<form name="x" class="header__form" action="/html/" method="post">
|
||||
|
||||
<div class="search search--header">
|
||||
<input name="q" autocomplete="off" class="search__input" id="search_form_input_homepage" type="text" value="cgb-awxp-eur-1" />
|
||||
<input name="b" id="search_button_homepage" class="search__button search__button--html" value="" title="Search" alt="Search" type="submit" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="frm__select">
|
||||
<select name="kl">
|
||||
|
||||
<option value="" >All Regions</option>
|
||||
|
||||
<option value="ar-es" >Argentina</option>
|
||||
|
||||
<option value="au-en" >Australia</option>
|
||||
|
||||
<option value="at-de" >Austria</option>
|
||||
|
||||
<option value="be-fr" >Belgium (fr)</option>
|
||||
|
||||
<option value="be-nl" >Belgium (nl)</option>
|
||||
|
||||
<option value="br-pt" >Brazil</option>
|
||||
|
||||
<option value="bg-bg" >Bulgaria</option>
|
||||
|
||||
<option value="ca-en" >Canada (en)</option>
|
||||
|
||||
<option value="ca-fr" >Canada (fr)</option>
|
||||
|
||||
<option value="ct-ca" >Catalonia</option>
|
||||
|
||||
<option value="cl-es" >Chile</option>
|
||||
|
||||
<option value="cn-zh" >China</option>
|
||||
|
||||
<option value="co-es" >Colombia</option>
|
||||
|
||||
<option value="hr-hr" >Croatia</option>
|
||||
|
||||
<option value="cz-cs" >Czech Republic</option>
|
||||
|
||||
<option value="dk-da" >Denmark</option>
|
||||
|
||||
<option value="ee-et" >Estonia</option>
|
||||
|
||||
<option value="fi-fi" >Finland</option>
|
||||
|
||||
<option value="fr-fr" >France</option>
|
||||
|
||||
<option value="de-de" >Germany</option>
|
||||
|
||||
<option value="gr-el" >Greece</option>
|
||||
|
||||
<option value="hk-tzh" >Hong Kong</option>
|
||||
|
||||
<option value="hu-hu" >Hungary</option>
|
||||
|
||||
<option value="in-en" >India (en)</option>
|
||||
|
||||
<option value="id-en" >Indonesia (en)</option>
|
||||
|
||||
<option value="ie-en" >Ireland</option>
|
||||
|
||||
<option value="il-en" >Israel (en)</option>
|
||||
|
||||
<option value="it-it" >Italy</option>
|
||||
|
||||
<option value="jp-jp" >Japan</option>
|
||||
|
||||
<option value="kr-kr" >Korea</option>
|
||||
|
||||
<option value="lv-lv" >Latvia</option>
|
||||
|
||||
<option value="lt-lt" >Lithuania</option>
|
||||
|
||||
<option value="my-en" >Malaysia (en)</option>
|
||||
|
||||
<option value="mx-es" >Mexico</option>
|
||||
|
||||
<option value="nl-nl" >Netherlands</option>
|
||||
|
||||
<option value="nz-en" >New Zealand</option>
|
||||
|
||||
<option value="no-no" >Norway</option>
|
||||
|
||||
<option value="pk-en" >Pakistan (en)</option>
|
||||
|
||||
<option value="pe-es" >Peru</option>
|
||||
|
||||
<option value="ph-en" >Philippines (en)</option>
|
||||
|
||||
<option value="pl-pl" >Poland</option>
|
||||
|
||||
<option value="pt-pt" >Portugal</option>
|
||||
|
||||
<option value="ro-ro" >Romania</option>
|
||||
|
||||
<option value="ru-ru" >Russia</option>
|
||||
|
||||
<option value="xa-ar" >Saudi Arabia</option>
|
||||
|
||||
<option value="sg-en" >Singapore</option>
|
||||
|
||||
<option value="sk-sk" >Slovakia</option>
|
||||
|
||||
<option value="sl-sl" >Slovenia</option>
|
||||
|
||||
<option value="za-en" >South Africa</option>
|
||||
|
||||
<option value="es-ca" >Spain (ca)</option>
|
||||
|
||||
<option value="es-es" >Spain (es)</option>
|
||||
|
||||
<option value="se-sv" >Sweden</option>
|
||||
|
||||
<option value="ch-de" >Switzerland (de)</option>
|
||||
|
||||
<option value="ch-fr" >Switzerland (fr)</option>
|
||||
|
||||
<option value="tw-tzh" >Taiwan</option>
|
||||
|
||||
<option value="th-en" >Thailand (en)</option>
|
||||
|
||||
<option value="tr-tr" >Turkey</option>
|
||||
|
||||
<option value="us-en" >US (English)</option>
|
||||
|
||||
<option value="us-es" >US (Spanish)</option>
|
||||
|
||||
<option value="ua-uk" >Ukraine</option>
|
||||
|
||||
<option value="uk-en" >United Kingdom</option>
|
||||
|
||||
<option value="vn-en" >Vietnam (en)</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="frm__select frm__select--last">
|
||||
<select class="" name="df">
|
||||
|
||||
<option value="" selected>Any Time</option>
|
||||
|
||||
<option value="d" >Past Day</option>
|
||||
|
||||
<option value="w" >Past Week</option>
|
||||
|
||||
<option value="m" >Past Month</option>
|
||||
|
||||
<option value="y" >Past Year</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Web results are present -->
|
||||
|
||||
<div>
|
||||
<div class="serp__results">
|
||||
<div id="links" class="results">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fvgdb.uk%2Fmario%2Dgolf%2Fgame%2F48484&rut=b6b8b1ab81b51d84905332762ecdb6db58a60d37b266e4b6fdb2cfd7005feea0">Get information and compare prices of Mario Golf for Game Boy | VGDb</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fvgdb.uk%2Fmario%2Dgolf%2Fgame%2F48484&rut=b6b8b1ab81b51d84905332762ecdb6db58a60d37b266e4b6fdb2cfd7005feea0">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/vgdb.uk.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fvgdb.uk%2Fmario%2Dgolf%2Fgame%2F48484&rut=b6b8b1ab81b51d84905332762ecdb6db58a60d37b266e4b6fdb2cfd7005feea0">
|
||||
vgdb.uk/mario-golf/game/48484
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fvgdb.uk%2Fmario%2Dgolf%2Fgame%2F48484&rut=b6b8b1ab81b51d84905332762ecdb6db58a60d37b266e4b6fdb2cfd7005feea0">045496730963. MPN. <b>CGB-AWXP-EUR-1</b>. Release Date.</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ebay.de%2Fitm%2FMario%2DGolf%2DNintendo%2DGame%2DBoy%2Dcolor%2DAdvance%2DGBC%2DGBA%2DCGB%2DAWXP%2DEUR%2F264853273128%3Fepid%3D167015543%26hash%3Ditem3daa7c3a28%3Ag%3A0U0AAOSwC%2D5fUT36&rut=5d364b7b299d63d00736bfd7fd0835ac27551cd84b2424019d36a682ad982aea">Mario Golf - Nintendo Game Boy color / Advance GBC GBA... | eBay</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ebay.de%2Fitm%2FMario%2DGolf%2DNintendo%2DGame%2DBoy%2Dcolor%2DAdvance%2DGBC%2DGBA%2DCGB%2DAWXP%2DEUR%2F264853273128%3Fepid%3D167015543%26hash%3Ditem3daa7c3a28%3Ag%3A0U0AAOSwC%2D5fUT36&rut=5d364b7b299d63d00736bfd7fd0835ac27551cd84b2424019d36a682ad982aea">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/www.ebay.de.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ebay.de%2Fitm%2FMario%2DGolf%2DNintendo%2DGame%2DBoy%2Dcolor%2DAdvance%2DGBC%2DGBA%2DCGB%2DAWXP%2DEUR%2F264853273128%3Fepid%3D167015543%26hash%3Ditem3daa7c3a28%3Ag%3A0U0AAOSwC%2D5fUT36&rut=5d364b7b299d63d00736bfd7fd0835ac27551cd84b2424019d36a682ad982aea">
|
||||
www.ebay.de/itm/Mario-Golf-Nintendo-Game-Boy-color-Advance-GBC-GBA-CGB-AWXP-EUR/264853273128?epid=167015543&hash=item3daa7c3a28:g:0U0AAOSwC-5fUT36
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ebay.de%2Fitm%2FMario%2DGolf%2DNintendo%2DGame%2DBoy%2Dcolor%2DAdvance%2DGBC%2DGBA%2DCGB%2DAWXP%2DEUR%2F264853273128%3Fepid%3D167015543%26hash%3Ditem3daa7c3a28%3Ag%3A0U0AAOSwC%2D5fUT36&rut=5d364b7b299d63d00736bfd7fd0835ac27551cd84b2424019d36a682ad982aea">Super mario bros deluxe game boy color game nintendo original gbc <b>EUR-1</b> version. Golf. Herstellernummer: <b>CGB-AWXP-EUR</b>. Herausgeber: Nintendo.</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=http%3A%2F%2Fpassat.neostrada.pl%2Fzabikt%2FGB.txt&rut=df8a2bed4111ed3dc8458c505782b07b995d248bdb2adf11a264f0c8e216b6ee">passat.neostrada.pl/zabikt/GB.txt</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=http%3A%2F%2Fpassat.neostrada.pl%2Fzabikt%2FGB.txt&rut=df8a2bed4111ed3dc8458c505782b07b995d248bdb2adf11a264f0c8e216b6ee">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/passat.neostrada.pl.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=http%3A%2F%2Fpassat.neostrada.pl%2Fzabikt%2FGB.txt&rut=df8a2bed4111ed3dc8458c505782b07b995d248bdb2adf11a264f0c8e216b6ee">
|
||||
passat.neostrada.pl/zabikt/GB.txt
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=http%3A%2F%2Fpassat.neostrada.pl%2Fzabikt%2FGB.txt&rut=df8a2bed4111ed3dc8458c505782b07b995d248bdb2adf11a264f0c8e216b6ee">and Jerry <b>CGB</b>-AW8A-<b>EUR</b> DMG-A03-10 <b>CGB</b>-AW8A- 11'00 Nintendo Wario Land 3 <b>CGB-AWXP-EUR-1</b> DMG-A08-01 <b>CGB</b>-<b>AWXP</b>- 07'99 <b>CGB</b>-BBZD- 05'02 Banpresto DragonBall Z - Legendare Superkampfer <b>CGB</b>-BCKP-<b>EUR</b> DMG-A07-01 <b>CGB</b>-BCKP- 10'00 THQ Chicken Run...</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.xe.com%2Fcurrencyconverter%2Fconvert%2F%3FAmount%3D1%26From%3DEUR%26To%3DXPF&rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">Convert 1 Euro to CFP Franc - EUR to XPF Exchange Rates | Xe</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.xe.com%2Fcurrencyconverter%2Fconvert%2F%3FAmount%3D1%26From%3DEUR%26To%3DXPF&rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/www.xe.com.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.xe.com%2Fcurrencyconverter%2Fconvert%2F%3FAmount%3D1%26From%3DEUR%26To%3DXPF&rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">
|
||||
www.xe.com/currencyconverter/convert/?Amount=1&From=EUR&To=XPF
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.xe.com%2Fcurrencyconverter%2Fconvert%2F%3FAmount%3D1%26From%3DEUR%26To%3DXPF&rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">Get live mid-market exchange rates, historical rates and data & currency charts for <b>EUR</b> to XPF with Xe's free Currency Converter.</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2F1prime.ru%2FFinancial_market%2F20210818%2F834477683.html&rut=98ca5523ff9959af22ddde1d49a41c29cec74571bf11f14960fb6e2753d47349">Евро сдувается: когда пора бежать в обменник - ПРАЙМ, 18.08.2021</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2F1prime.ru%2FFinancial_market%2F20210818%2F834477683.html&rut=98ca5523ff9959af22ddde1d49a41c29cec74571bf11f14960fb6e2753d47349">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/1prime.ru.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2F1prime.ru%2FFinancial_market%2F20210818%2F834477683.html&rut=98ca5523ff9959af22ddde1d49a41c29cec74571bf11f14960fb6e2753d47349">
|
||||
1prime.ru/Financial_market/20210818/834477683.html
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2F1prime.ru%2FFinancial_market%2F20210818%2F834477683.html&rut=98ca5523ff9959af22ddde1d49a41c29cec74571bf11f14960fb6e2753d47349"><b>EUR</b>/RUB. <b>EUR</b>/CHF.</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.cgb.fr%2Fespagne%2Dserie%2Deuro%2Dbrillant%2Duniversel%2D2002%2Dbu%2CEA_102_101_117_95_54_57_48_52_52_51%2Ca.html&rut=4e2756edf796854d544428c15a6044ce2bba3dc48072b3b8ea2b6c465b3cd73d">ESPAGNE SÉRIE Euro BRILLANT UNIVERSEL 2002 Madrid...</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.cgb.fr%2Fespagne%2Dserie%2Deuro%2Dbrillant%2Duniversel%2D2002%2Dbu%2CEA_102_101_117_95_54_57_48_52_52_51%2Ca.html&rut=4e2756edf796854d544428c15a6044ce2bba3dc48072b3b8ea2b6c465b3cd73d">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/www.cgb.fr.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.cgb.fr%2Fespagne%2Dserie%2Deuro%2Dbrillant%2Duniversel%2D2002%2Dbu%2CEA_102_101_117_95_54_57_48_52_52_51%2Ca.html&rut=4e2756edf796854d544428c15a6044ce2bba3dc48072b3b8ea2b6c465b3cd73d">
|
||||
www.cgb.fr/espagne-serie-euro-brillant-universel-2002-bu,EA_102_101_117_95_54_57_48_52_52_51,a.html
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.cgb.fr%2Fespagne%2Dserie%2Deuro%2Dbrillant%2Duniversel%2D2002%2Dbu%2CEA_102_101_117_95_54_57_48_52_52_51%2Ca.html&rut=4e2756edf796854d544428c15a6044ce2bba3dc48072b3b8ea2b6c465b3cd73d"><b>cgb</b>.fr. La vente sera clôturée à l'heure indiquée sur la fiche descriptive, toute offre reçue après l'heure de clôture ne sera pas validée. <b>cgb</b>.fr utilise des cookies pour vous garantir une meilleure expérience utilisateur et réaliser des statistiques de visites.</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.chamber%2Dinternational.com%2Fexporting%2Dchamber%2Dinternational%2Fdocumentation%2Dfor%2Dexport%2Dand%2Dimport%2Feur%2D1%2Dcertificates%2F&rut=faece7b1a1828d2ac5bee57a7c3582afbbd2defc0f12b9cfdabcce29c7728229">EUR1 certificates</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.chamber%2Dinternational.com%2Fexporting%2Dchamber%2Dinternational%2Fdocumentation%2Dfor%2Dexport%2Dand%2Dimport%2Feur%2D1%2Dcertificates%2F&rut=faece7b1a1828d2ac5bee57a7c3582afbbd2defc0f12b9cfdabcce29c7728229">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/www.chamber-international.com.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.chamber%2Dinternational.com%2Fexporting%2Dchamber%2Dinternational%2Fdocumentation%2Dfor%2Dexport%2Dand%2Dimport%2Feur%2D1%2Dcertificates%2F&rut=faece7b1a1828d2ac5bee57a7c3582afbbd2defc0f12b9cfdabcce29c7728229">
|
||||
www.chamber-international.com/exporting-chamber-international/documentation-for-export-and-import/eur-1-certificates/
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.chamber%2Dinternational.com%2Fexporting%2Dchamber%2Dinternational%2Fdocumentation%2Dfor%2Dexport%2Dand%2Dimport%2Feur%2D1%2Dcertificates%2F&rut=faece7b1a1828d2ac5bee57a7c3582afbbd2defc0f12b9cfdabcce29c7728229"><b>EUR</b> <b>1</b> certificates are issued by Chamber International under authority from HM Customs and Excise. No matter where you are in the UK we have a dedicated service that can walk you through the <b>EUR1</b> process, to take away the confusion so you can get on with what matters to you.</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEUR.1_movement_certificate&rut=24216430601fd3be32ae5f33be1579dd66e4e2a2b52d4e8edf1d1df49da81a76">EUR.1 movement certificate - Wikipedia</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEUR.1_movement_certificate&rut=24216430601fd3be32ae5f33be1579dd66e4e2a2b52d4e8edf1d1df49da81a76">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/en.wikipedia.org.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEUR.1_movement_certificate&rut=24216430601fd3be32ae5f33be1579dd66e4e2a2b52d4e8edf1d1df49da81a76">
|
||||
en.wikipedia.org/wiki/EUR.1_movement_certificate
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEUR.1_movement_certificate&rut=24216430601fd3be32ae5f33be1579dd66e4e2a2b52d4e8edf1d1df49da81a76">The <b>EUR.1</b> movement certificate (also known as <b>EUR.1</b> certificate, or <b>EUR.1</b>) is a form used in international commodity traffic. The <b>EUR.1</b> is most importantly recognized as a certificate of origin in...</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.tinko.ru%2Fcatalog%2Fproduct%2F245755%2F&rut=6690f23522657070be76b9ad00b6ba1084ebed21008130b7a37e8c3fa25874ac">CGB-1U-19 (7113c): Медная шина заземления, 19"</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.tinko.ru%2Fcatalog%2Fproduct%2F245755%2F&rut=6690f23522657070be76b9ad00b6ba1084ebed21008130b7a37e8c3fa25874ac">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/www.tinko.ru.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.tinko.ru%2Fcatalog%2Fproduct%2F245755%2F&rut=6690f23522657070be76b9ad00b6ba1084ebed21008130b7a37e8c3fa25874ac">
|
||||
www.tinko.ru/catalog/product/245755/
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.tinko.ru%2Fcatalog%2Fproduct%2F245755%2F&rut=6690f23522657070be76b9ad00b6ba1084ebed21008130b7a37e8c3fa25874ac"><b>CGB</b>-1U-19 (7113c). Избранное. Сравнить.</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.justetf.com%2Fde%2Fetf%2Dprofile.html%3Fisin%3DIE00B3RBWM25&rut=6ef75eb1798a163400e483634db05a9bb8eb3b05d1b4b99c19cd2b62b91313c8">Vanguard FTSE All-World UCITS ETF Distributing | A1JX52</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.justetf.com%2Fde%2Fetf%2Dprofile.html%3Fisin%3DIE00B3RBWM25&rut=6ef75eb1798a163400e483634db05a9bb8eb3b05d1b4b99c19cd2b62b91313c8">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/www.justetf.com.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.justetf.com%2Fde%2Fetf%2Dprofile.html%3Fisin%3DIE00B3RBWM25&rut=6ef75eb1798a163400e483634db05a9bb8eb3b05d1b4b99c19cd2b62b91313c8">
|
||||
www.justetf.com/de/etf-profile.html?isin=IE00B3RBWM25
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.justetf.com%2Fde%2Fetf%2Dprofile.html%3Fisin%3DIE00B3RBWM25&rut=6ef75eb1798a163400e483634db05a9bb8eb3b05d1b4b99c19cd2b62b91313c8">Ausschüttungen der letzten 12 Monate. <b>EUR</b> 1,42. <b>EUR</b> 1,42. Premium-Funktion. 2020.</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.shiphub.co%2Feur%2D1%2Ddocument%2F&rut=a7ea72c573daebf0f67f613309117c6ec11844bff0cd1b2afbb2aa37948f3186">EUR 1 document - Information about EUR 1 | ShipHub</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.shiphub.co%2Feur%2D1%2Ddocument%2F&rut=a7ea72c573daebf0f67f613309117c6ec11844bff0cd1b2afbb2aa37948f3186">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/www.shiphub.co.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.shiphub.co%2Feur%2D1%2Ddocument%2F&rut=a7ea72c573daebf0f67f613309117c6ec11844bff0cd1b2afbb2aa37948f3186">
|
||||
www.shiphub.co/eur-1-document/
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.shiphub.co%2Feur%2D1%2Ddocument%2F&rut=a7ea72c573daebf0f67f613309117c6ec11844bff0cd1b2afbb2aa37948f3186">The <b>EUR</b> <b>1</b> document also helps to set the rates of customs duties. This is related to the fact that goods from one region of the world in different countries The <b>EUR</b> <b>1</b> document is one of the most common certificates of origin on the international market. Well, it is issued when there is a preferential bilateral...</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Feur1.ch%2Feur1&rut=9d288ecd9c501724cd409741f2f94aa7ae11ea635feb3abba833b9ebc779b1fa">EUR1 - eur1.ch Informationen zu EUR1 bestellen</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Feur1.ch%2Feur1&rut=9d288ecd9c501724cd409741f2f94aa7ae11ea635feb3abba833b9ebc779b1fa">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/eur1.ch.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Feur1.ch%2Feur1&rut=9d288ecd9c501724cd409741f2f94aa7ae11ea635feb3abba833b9ebc779b1fa">
|
||||
eur1.ch/eur1
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Feur1.ch%2Feur1&rut=9d288ecd9c501724cd409741f2f94aa7ae11ea635feb3abba833b9ebc779b1fa"><b>EUR1</b> Formular online ausfüllen, <b>EUR1</b> Download, <b>EUR1</b> Fahrzeug, <b>EUR1</b> PKW, <b>EUR1</b> Export, Präferenz, <b>EUR1</b> Formular online, Ursprungserklärung Ein <b>EUR.1</b> ist ein Formular, welches im internationalen Handelswarenverkehr angewendet wird. Die Anwendung basiert auf diversen bi- und...</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fec.europa.eu%2Finfo%2Fstrategy%2Frecovery%2Dplan%2Deurope_en&rut=4e8c965208bfb5af73f9a91301e671a1f719f15a7cb4e4f00761367ceadfcbe2">Recovery plan for Europe | European Commission</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fec.europa.eu%2Finfo%2Fstrategy%2Frecovery%2Dplan%2Deurope_en&rut=4e8c965208bfb5af73f9a91301e671a1f719f15a7cb4e4f00761367ceadfcbe2">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/ec.europa.eu.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fec.europa.eu%2Finfo%2Fstrategy%2Frecovery%2Dplan%2Deurope_en&rut=4e8c965208bfb5af73f9a91301e671a1f719f15a7cb4e4f00761367ceadfcbe2">
|
||||
ec.europa.eu/info/strategy/recovery-plan-europe_en
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fec.europa.eu%2Finfo%2Fstrategy%2Frecovery%2Dplan%2Deurope_en&rut=4e8c965208bfb5af73f9a91301e671a1f719f15a7cb4e4f00761367ceadfcbe2"><b>EUR</b>, der skal bidrage til opbygningen af et grønnere, mere digitalt og mere modstandsdygtigt Europa). (Bugetul UE: Comisia Europeană salută acordul privind pachetul în valoare de 1,8 mii de miliarde <b>EUR</b> care va contribui la construirea unei Europe mai verzi, mai digitale și mai reziliente).</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
|
||||
|
||||
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
|
||||
|
||||
<h2 class="result__title">
|
||||
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D685J1hos%2Dzw&rut=0de6e73357766837f1535544102aba3a21dfeb0ebe95183417e313e54b315bf1">EUR.1 - einfach erklärt (2020) - YouTube</a>
|
||||
|
||||
</h2>
|
||||
|
||||
|
||||
|
||||
<div class="result__extras">
|
||||
<div class="result__extras__url">
|
||||
<span class="result__icon">
|
||||
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D685J1hos%2Dzw&rut=0de6e73357766837f1535544102aba3a21dfeb0ebe95183417e313e54b315bf1">
|
||||
<img class="result__icon__img" width="16" height="16" alt=""
|
||||
src="//external-content.duckduckgo.com/ip3/www.youtube.com.ico" name="i15" />
|
||||
</a>
|
||||
|
||||
</span>
|
||||
|
||||
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D685J1hos%2Dzw&rut=0de6e73357766837f1535544102aba3a21dfeb0ebe95183417e313e54b315bf1">
|
||||
www.youtube.com/watch?v=685J1hos-zw
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D685J1hos%2Dzw&rut=0de6e73357766837f1535544102aba3a21dfeb0ebe95183417e313e54b315bf1">Die <b>EUR.1</b> ermöglicht Importeuren, Waren zu sogenannten Präferenzzollsätzen einzuführen. Sie gilt für fast alle Länder mit denen die EU Freihandelsabkommen abgeschlossen hat. Notwendig für eine rechtmäßigen Ausstellung ist...</a>
|
||||
|
||||
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="nav-link">
|
||||
<form action="/html/" method="post">
|
||||
<input type="submit" class='btn btn--alt' value="Next" />
|
||||
<input type="hidden" name="q" value="cgb-awxp-eur-1" />
|
||||
<input type="hidden" name="s" value="30" />
|
||||
<input type="hidden" name="nextParams" value="" />
|
||||
<input type="hidden" name="v" value="l" />
|
||||
<input type="hidden" name="o" value="json" />
|
||||
<input type="hidden" name="dc" value="15" />
|
||||
<input type="hidden" name="api" value="d.js" />
|
||||
<input type="hidden" name="vqd" value="3-170112077246644806044058132965997084141-24182048638364464238089820915205130627" />
|
||||
|
||||
|
||||
|
||||
|
||||
<input name="kl" value="wt-wt" type="hidden" />
|
||||
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class=" feedback-btn">
|
||||
<a rel="nofollow" href="//duckduckgo.com/feedback.html" target="_new">Feedback</a>
|
||||
</div>
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
</div> <!-- links wrapper //-->
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bottom_spacing2"></div>
|
||||
|
||||
|
||||
<img src="//duckduckgo.com/t/sl_h"/>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
""".trimIndent()
|
||||
|
||||
val cart = DuckDuckGoResultParser.parse(html, "cgb-awxp-eur-1")
|
||||
assertEquals("mario golf", cart?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_NoResultsFound_returnsNull() {
|
||||
val result = DuckDuckGoResultParser.parse("whooptie-doo", "some-code")
|
||||
assertNull1(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_ResultWithStuffBetweenBrackets_removesThose() {
|
||||
val html = """
|
||||
<a rel="nofollow" class="result__a" href="url">My Little Pony (USA Release)!</a>
|
||||
""".trimIndent()
|
||||
|
||||
val cart = DuckDuckGoResultParser.parse(html, "some-code")
|
||||
assertEquals("my little pony", cart?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_ResultWithCodeAgainInResult_removesRedundantCode() {
|
||||
val html = """
|
||||
<a rel="nofollow" class="result__a" href="url">My Little Pony (USA Release) DMG-PONY-007 ebay</a>
|
||||
""".trimIndent()
|
||||
|
||||
val cart = DuckDuckGoResultParser.parse(html, "DMG-PONY-007")
|
||||
assertEquals("my little pony", cart?.title)
|
||||
assertEquals("DMG-PONY-007", cart?.code)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,14 +1,16 @@
|
|||
package be.kuleuven.howlongtobeat.hltb
|
||||
|
||||
import be.kuleuven.howlongtobeat.cartridges.Cartridge
|
||||
import junit.framework.TestCase.assertNull
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
|
||||
class HowLongToBeatResultParserTest {
|
||||
@Test
|
||||
fun parseWithEmptyStringReturnsEmptyList() {
|
||||
val result = HowLongToBeatResultParser.parse("")
|
||||
assertEquals(0, result.size)
|
||||
fun parseWithEmptyStringReturnsNull() {
|
||||
val result = HowLongToBeatResultParser.parse("", Cartridge.UNKNOWN_CART)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -66,16 +68,22 @@ I/System.out: <div class="search_list_tidbit text_white shadow_text">Ma
|
|||
<img alt="Box Art" src="/games/250px-Super_Mario_Land_2_box_art.jpg" />
|
||||
</a>
|
||||
""".trimIndent()
|
||||
val result = HowLongToBeatResultParser.parse(html)
|
||||
val result = HowLongToBeatResultParser.parse(html, Cartridge("DMG", "name", "DMG-CODE1"))!!
|
||||
val smland = result[0]
|
||||
val sm3dland = result[1]
|
||||
|
||||
assertEquals(3, result.size)
|
||||
|
||||
assertEquals("Super Mario Land", smland.title)
|
||||
assertEquals("Super Mario 3D Land", sm3dland.title)
|
||||
assertEquals("https://howlongtobeat.com/games/250px-Supermariolandboxart.jpg", smland.boxart)
|
||||
assertEquals("https://howlongtobeat.com/games/250px-Super-Mario-3D-Land-Logo.jpg", sm3dland.boxart)
|
||||
|
||||
assertEquals("https://howlongtobeat.com/games/250px-Supermariolandboxart.jpg", smland.boxartUrl)
|
||||
assertEquals("https://howlongtobeat.com/games/250px-Super-Mario-3D-Land-Logo.jpg", sm3dland.boxartUrl)
|
||||
|
||||
assertEquals(1.0, smland.howlong, 0.0)
|
||||
assertEquals(6.5, sm3dland.howlong, 0.0)
|
||||
|
||||
assertEquals("DMG-CODE1", smland.cartCode)
|
||||
assertEquals("DMG-CODE1", sm3dland.cartCode)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue