hltb example: refactor find game progression system, add duckduckgo as fallback game repository

This commit is contained in:
Wouter Groeneveld 2021-08-19 09:46:03 +02:00
parent adde1e8e70
commit 561756ed6f
39 changed files with 1674 additions and 180 deletions

View File

@ -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

View File

@ -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.
}
}

View File

@ -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")
}
}

View File

@ -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()
}
}

View File

@ -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 type name title slug url contributor code stamp board_type board_circled_letters board_extra_label board_calendar_short board_calendar board_year board_month rom_kind rom_label rom_manufacturer rom_manufacturer_name rom_calendar_short rom_calendar rom_year rom_month rom_week mapper_kind mapper_label mapper_manufacturer mapper_manufacturer_name mapper_calendar_short mapper_calendar mapper_year mapper_month mapper_week ram_kind ram_label ram_manufacturer ram_manufacturer_name ram_calendar_short ram_calendar ram_year ram_month ram_week ram_protector_kind ram_protector_label ram_protector_manufacturer ram_protector_manufacturer_name ram_protector_calendar_short ram_protector_calendar ram_protector_year ram_protector_month ram_protector_week crystal_kind crystal_label crystal_manufacturer crystal_manufacturer_name crystal_calendar_short crystal_calendar crystal_year crystal_month crystal_week rom2_kind rom2_label rom2_manufacturer rom2_manufacturer_name rom2_calendar_short rom2_calendar rom2_year rom2_month rom2_week flash_kind flash_label flash_manufacturer flash_manufacturer_name flash_calendar_short flash_calendar flash_year flash_month flash_week line_decoder_kind line_decoder_label line_decoder_manufacturer line_decoder_manufacturer_name line_decoder_calendar_short line_decoder_calendar line_decoder_year line_decoder_month line_decoder_week eeprom_kind eeprom_label eeprom_manufacturer eeprom_manufacturer_name eeprom_calendar_short eeprom_calendar eeprom_year eeprom_month eeprom_week accelerometer_kind accelerometer_label accelerometer_manufacturer accelerometer_manufacturer_name accelerometer_calendar_short accelerometer_calendar accelerometer_year accelerometer_month accelerometer_week u4_kind u4_label u4_manufacturer u4_manufacturer_name u4_calendar_short u4_calendar u4_year u4_month u4_week u5_kind u5_label u5_manufacturer u5_manufacturer_name u5_calendar_short u5_calendar u5_year u5_month u5_week crystal
293 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
294 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
295 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
296 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
297 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
298 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
299 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
300 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

View File

@ -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()

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}
*/
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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()

View File

@ -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 {

View File

@ -0,0 +1,3 @@
package be.kuleuven.howlongtobeat
class UnableToFindGameException(message: String?) : Throwable(message)

View File

@ -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()

View File

@ -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)))
})
}
}
}

View File

@ -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
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)"
}

View File

@ -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 {

View File

@ -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"
}
}

View File

@ -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>)

View File

@ -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

View File

@ -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>

View File

@ -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"

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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")

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;rut=5d364b7b299d63d00736bfd7fd0835ac27551cd84b2424019d36a682ad982aea">
www.ebay.de/itm/Mario-Golf-Nintendo-Game-Boy-color-Advance-GBC-GBA-CGB-AWXP-EUR/264853273128?epid=167015543&amp;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&amp;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&amp;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&amp;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&amp;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&amp;rut=df8a2bed4111ed3dc8458c505782b07b995d248bdb2adf11a264f0c8e216b6ee">and Jerry <b>CGB</b>-AW8A-<b>EUR</b> DMG-A03-10 <b>CGB</b>-AW8A- 11&#x27;00 Nintendo Wario Land 3 <b>CGB-AWXP-EUR-1</b> DMG-A08-01 <b>CGB</b>-<b>AWXP</b>- 07&#x27;99 <b>CGB</b>-BBZD- 05&#x27;02 Banpresto DragonBall Z - Legendare Superkampfer <b>CGB</b>-BCKP-<b>EUR</b> DMG-A07-01 <b>CGB</b>-BCKP- 10&#x27;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&amp;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&amp;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&amp;rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">
www.xe.com/currencyconverter/convert/?Amount=1&amp;From=EUR&amp;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&amp;rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">Get live mid-market exchange rates, historical rates and data &amp; currency charts for <b>EUR</b> to XPF with Xe&#x27;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;rut=4e2756edf796854d544428c15a6044ce2bba3dc48072b3b8ea2b6c465b3cd73d"><b>cgb</b>.fr. La vente sera clôturée à l&#x27;heure indiquée sur la fiche descriptive, toute offre reçue après l&#x27;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;rut=6690f23522657070be76b9ad00b6ba1084ebed21008130b7a37e8c3fa25874ac">CGB-1U-19 (7113c): Медная шина заземления, 19&quot;</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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;rut=9d288ecd9c501724cd409741f2f94aa7ae11ea635feb3abba833b9ebc779b1fa">
eur1.ch/eur1
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Feur1.ch%2Feur1&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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)
}
}

View File

@ -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)
}
}