🔀 Merge branch 'website/epic-games' into next main v2
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Thu, 11 Apr 2024 09:46:27 +0000 (11:46 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Thu, 11 Apr 2024 09:46:41 +0000 (11:46 +0200)
build.gradle
src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesFilter.kt
src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesMerger.kt [new file with mode: 0644]
src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/FreeGame.kt [new file with mode: 0644]
src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/FreeGamesState.kt [new file with mode: 0644]
src/main/kotlin/net/pterodactylus/rhynodge/watchers/EpicGamesWatcher.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesMergerTest.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/FreeGamesStateTest.kt [new file with mode: 0644]

index 85cf007..9e286ff 100644 (file)
@@ -55,7 +55,7 @@ dependencies {
     implementation group: 'com.google.code.findbugs', name: 'jsr305', version: '3.0.2'
 
     testImplementation group: "junit", name: "junit", version: "4.13.2"
-    testImplementation group: "org.hamcrest", name: "hamcrest-library", version: "1.3"
+    testImplementation group: "org.hamcrest", name: "hamcrest", version: "2.2"
     testImplementation group: "org.mockito", name: "mockito-core", version: "5.11.0"
     testImplementation group: "com.spotify", name: "hamcrest-jackson", version: "1.3.2"
 }
index 31bb11f..e159dff 100644 (file)
@@ -3,7 +3,6 @@ package net.pterodactylus.rhynodge.filters.webpages.epicgames
 import com.fasterxml.jackson.databind.JsonNode
 import net.pterodactylus.rhynodge.Filter
 import net.pterodactylus.rhynodge.State
-import net.pterodactylus.rhynodge.states.AbstractState
 import net.pterodactylus.rhynodge.states.JsonState
 import java.time.Instant
 
@@ -25,25 +24,10 @@ class EpicGamesFilter : Filter {
 
        private fun getPromotionalOfferDate(gameJson: JsonNode, date: String) = listOf("promotionalOffers", "upcomingPromotionalOffers")
                .map { "/promotions/$it/0/promotionalOffers/0/$date" }
-               .map { gameJson.at(it) }
-               .filter { !it.isMissingNode }
-               .map { it.asText() }
+               .map(gameJson::at)
+               .filterNot(JsonNode::isMissingNode)
+               .map(JsonNode::asText)
                .map(Instant::parse)
                .first()
 
 }
-
-data class FreeGame(
-       val title: String,
-       val imageUrl: String,
-       val startDate: Instant,
-       val endDate: Instant
-)
-
-class FreeGamesState(val games: Set<FreeGame>) : AbstractState(true) {
-
-       override fun plainText(): String {
-               TODO("Not yet implemented")
-       }
-
-}
diff --git a/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesMerger.kt b/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesMerger.kt
new file mode 100644 (file)
index 0000000..80ab923
--- /dev/null
@@ -0,0 +1,18 @@
+package net.pterodactylus.rhynodge.filters.webpages.epicgames
+
+import net.pterodactylus.rhynodge.Merger
+import net.pterodactylus.rhynodge.State
+
+class EpicGamesMerger : Merger {
+
+       override fun mergeStates(previousState: State, currentState: State): State {
+               previousState as? FreeGamesState ?: throw IllegalArgumentException("previousState is not a FreeGamesState")
+               currentState as? FreeGamesState ?: throw IllegalArgumentException("currentState is not a FreeGamesState")
+
+               val oldGames = previousState.games
+               val newGames = currentState.games
+
+               return FreeGamesState(newGames, (newGames - oldGames).isNotEmpty())
+       }
+
+}
diff --git a/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/FreeGame.kt b/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/FreeGame.kt
new file mode 100644 (file)
index 0000000..e5d4abf
--- /dev/null
@@ -0,0 +1,10 @@
+package net.pterodactylus.rhynodge.filters.webpages.epicgames
+
+import java.time.Instant
+
+data class FreeGame(
+       val title: String,
+       val imageUrl: String,
+       val startDate: Instant,
+       val endDate: Instant
+)
diff --git a/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/FreeGamesState.kt b/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/FreeGamesState.kt
new file mode 100644 (file)
index 0000000..47a78fd
--- /dev/null
@@ -0,0 +1,67 @@
+package net.pterodactylus.rhynodge.filters.webpages.epicgames
+
+import kotlinx.html.body
+import kotlinx.html.div
+import kotlinx.html.dom.createHTMLDocument
+import kotlinx.html.dom.serialize
+import kotlinx.html.head
+import kotlinx.html.html
+import kotlinx.html.img
+import kotlinx.html.style
+import kotlinx.html.unsafe
+import net.pterodactylus.rhynodge.states.AbstractState
+import java.time.ZoneId
+import java.util.Comparator.comparing
+
+class FreeGamesState(val games: Set<FreeGame>, private val triggered: Boolean = false, private val timezone: ZoneId = ZoneId.systemDefault()) : AbstractState(true) {
+
+       override fun plainText() = games
+               .sortedWith(comparing(FreeGame::startDate).thenBy(FreeGame::title))
+               .joinToString("\n") { game ->
+                       "${game.title}: ${"%tF %<tT".format(game.startDate.atZone(timezone))} - ${"%tF %<tT".format(game.endDate.atZone(timezone))} (${game.imageUrl})"
+               }
+
+       override fun htmlText() = createHTMLDocument().html {
+               head {
+                       style("text/css") {
+                               unsafe {
+                                       raw(
+                                               """
+                                               .game { display: inline-grid; width: 200px; height: 350px; grid-template-rows: 0fr 1fr 0fr 0fr; font-family: 'Recursive Sans Linear Static', Roboto, serif; color: white; text-shadow: 2px 2px black; margin: 0 1ex 1ex 0; }
+                                               .game .game-image { grid-area: 1/1/5/2; }
+                                               .game .game-image img { object-fit: cover; width: 100%; height: 100%; }
+                                               .game .game-title { grid-area: 1/1/2/2; padding: 1ex; font-size: 150%; font-weight: bold; }
+                                               .game .game-start { grid-area: 3/1/4/2; padding: 1ex 1ex 0ex 1ex; }
+                                               .game .game-end { grid-area: 4/1/5/2; padding: 0ex 1ex 1ex 1ex; }
+                                       """
+                                       )
+                               }
+                       }
+               }
+               body {
+                       div("games") {
+                               games
+                                       .sortedWith(comparing(FreeGame::startDate).thenBy(FreeGame::title))
+                                       .forEach { game ->
+                                               div("game") {
+                                                       div("game-image") {
+                                                               img(src = game.imageUrl)
+                                                       }
+                                                       div("game-title") {
+                                                               +game.title
+                                                       }
+                                                       div("game-start") {
+                                                               +"%tF %<tT".format(game.startDate.atZone(timezone))
+                                                       }
+                                                       div("game-end") {
+                                                               +"%tF %<tT".format(game.endDate.atZone(timezone))
+                                                       }
+                                               }
+                                       }
+                       }
+               }
+       }.serialize(prettyPrint = true)
+
+       override fun triggered() = triggered
+
+}
diff --git a/src/main/kotlin/net/pterodactylus/rhynodge/watchers/EpicGamesWatcher.kt b/src/main/kotlin/net/pterodactylus/rhynodge/watchers/EpicGamesWatcher.kt
new file mode 100644 (file)
index 0000000..e6ed542
--- /dev/null
@@ -0,0 +1,12 @@
+package net.pterodactylus.rhynodge.watchers
+
+import net.pterodactylus.rhynodge.filters.JsonFilter
+import net.pterodactylus.rhynodge.filters.webpages.epicgames.EpicGamesFilter
+import net.pterodactylus.rhynodge.filters.webpages.epicgames.EpicGamesMerger
+import net.pterodactylus.rhynodge.queries.HttpQuery
+
+class EpicGamesWatcher : DefaultWatcher(query, filters, merger)
+
+private val query = HttpQuery("https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions?locale=en-US&country=DE&allowCountries=DE")
+private val filters = listOf(JsonFilter(), EpicGamesFilter())
+private val merger = EpicGamesMerger()
diff --git a/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesMergerTest.kt b/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesMergerTest.kt
new file mode 100644 (file)
index 0000000..49ade1f
--- /dev/null
@@ -0,0 +1,87 @@
+package net.pterodactylus.rhynodge.filters.webpages.epicgames
+
+import net.pterodactylus.rhynodge.Merger
+import net.pterodactylus.rhynodge.states.StateManagerTest.TestState
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.contains
+import org.hamcrest.Matchers.empty
+import org.hamcrest.Matchers.equalTo
+import org.hamcrest.Matchers.instanceOf
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import java.time.Instant
+
+class EpicGamesMergerTest {
+
+       @Test
+       fun `merger implements Merger interface`() {
+               assertThat(merger, instanceOf(Merger::class.java))
+       }
+
+       @Test
+       fun `merger throws exception if previous state is not a free games state`() {
+               assertThrows(IllegalArgumentException::class.java) { merger.mergeStates(TestState(), emptyState) }
+       }
+
+       @Test
+       fun `merger throws exception if current state is not a free games state`() {
+               assertThrows(IllegalArgumentException::class.java) { merger.mergeStates(emptyState, TestState()) }
+       }
+
+       @Test
+       fun `merger given two empty free game states returns an empty state`() {
+               val mergedState = merger.mergeStates(emptyState, emptyState) as FreeGamesState
+               assertThat(mergedState.games, empty())
+       }
+
+       @Test
+       fun `merging two empty states results in not-triggered state`() {
+               val mergedState = merger.mergeStates(emptyState, emptyState) as FreeGamesState
+               assertThat(mergedState.triggered(), equalTo(false))
+       }
+
+       @Test
+       fun `merging state without games into state with games returns state without games`() {
+               val mergedState = merger.mergeStates(stateWithOneGame, emptyState) as FreeGamesState
+               assertThat(mergedState.games, empty())
+       }
+
+       @Test
+       fun `merging state without games into state with games returns not-triggered state`() {
+               val mergedState = merger.mergeStates(stateWithOneGame, emptyState) as FreeGamesState
+               assertThat(mergedState.triggered(), equalTo(false))
+       }
+
+       @Test
+       fun `merging state with games into state without games returns state with games`() {
+               val mergedState = merger.mergeStates(emptyState, stateWithOneGame) as FreeGamesState
+               assertThat(mergedState.games, contains(game1))
+       }
+
+       @Test
+       fun `merging state with games into state without games returns a triggered state`() {
+               val mergedState = merger.mergeStates(emptyState, stateWithOneGame) as FreeGamesState
+               assertThat(mergedState.triggered(), equalTo(true))
+       }
+
+       @Test
+       fun `merge state with more games into state with less games returns a triggered state`() {
+               val mergedState = merger.mergeStates(stateWithOneGame, stateWithTwoGames) as FreeGamesState
+               assertThat(mergedState.triggered(), equalTo(true))
+       }
+
+       @Test
+       fun `merge state with less games into state with more games does not return a triggered state`() {
+               val mergedState = merger.mergeStates(stateWithTwoGames, stateWithOneGame) as FreeGamesState
+               assertThat(mergedState.triggered(), equalTo(false))
+       }
+
+       private val merger = EpicGamesMerger()
+
+}
+
+private val emptyState = FreeGamesState(emptySet())
+private val game1 = FreeGame("1", "i", Instant.now(), Instant.now())
+private val game2 = FreeGame("2", "i", Instant.now(), Instant.now())
+private val stateWithOneGame = FreeGamesState(setOf(game1))
+private val stateWithTwoGames = FreeGamesState(setOf(game1, game2))
diff --git a/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/FreeGamesStateTest.kt b/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/FreeGamesStateTest.kt
new file mode 100644 (file)
index 0000000..15679c7
--- /dev/null
@@ -0,0 +1,60 @@
+package net.pterodactylus.rhynodge.filters.webpages.epicgames
+
+import net.pterodactylus.rhynodge.Reaction
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.contains
+import org.hamcrest.Matchers.equalTo
+import org.jsoup.Jsoup
+import org.junit.Test
+import java.time.Instant
+import java.time.ZoneOffset
+
+class FreeGamesStateTest {
+
+       @Test
+       fun `can create free games state`() {
+               FreeGamesState(emptySet())
+       }
+
+       @Test
+       fun `state lists all games in text output`() {
+               val output = state.output(Reaction("", null, null, null)).text("text/plain")
+               assertThat(
+                       output, equalTo(
+                               listOf(
+                                       "Good Game: 1970-01-01 00:16:40 - 1970-01-01 00:33:20 (https://good.game/image.jpg)",
+                                       "Best Game: 1970-01-01 00:33:20 - 1970-01-01 00:50:00 (https://best.game/image.webp)",
+                                       "Better Game: 1970-01-01 00:50:00 - 1970-01-01 01:06:40 (https://better.game/image.png)",
+                               ).joinToString("\n")
+                       )
+               )
+       }
+
+       @Test
+       fun `state lists all games in HTML output`() {
+               val output = state.output(Reaction("", null, null, null)).text("text/html")
+               val parsedOutput = Jsoup.parse(output)
+               assertThat(
+                       parsedOutput.select(".game").map {
+                               listOf(
+                                       it.select(".game-title").text(),
+                                       it.select(".game-image img").attr("src"),
+                                       it.select(".game-start").text(),
+                                       it.select(".game-end").text()
+                               ).joinToString(", ")
+                       }, contains(
+                               "Good Game, https://good.game/image.jpg, 1970-01-01 00:16:40, 1970-01-01 00:33:20",
+                               "Best Game, https://best.game/image.webp, 1970-01-01 00:33:20, 1970-01-01 00:50:00",
+                               "Better Game, https://better.game/image.png, 1970-01-01 00:50:00, 1970-01-01 01:06:40",
+                       )
+               )
+       }
+
+}
+
+private val freeGames = setOf(
+       FreeGame("Good Game", "https://good.game/image.jpg", Instant.ofEpochSecond(1000), Instant.ofEpochSecond(2000)),
+       FreeGame("Better Game", "https://better.game/image.png", Instant.ofEpochSecond(3000), Instant.ofEpochSecond(4000)),
+       FreeGame("Best Game", "https://best.game/image.webp", Instant.ofEpochSecond(2000), Instant.ofEpochSecond(3000)),
+)
+private val state = FreeGamesState(freeGames, timezone = ZoneOffset.UTC)