🚸 Improve mail with movies from the Savoy
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Sun, 25 Feb 2024 00:54:01 +0000 (01:54 +0100)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Sun, 25 Feb 2024 00:56:09 +0000 (01:56 +0100)
src/main/java/net/pterodactylus/rhynodge/watchers/SavoyTicketWatcher.java
src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieState.kt [new file with mode: 0644]
src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/SavoyMerger.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieStateTest.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/SavoyMergerTest.kt [new file with mode: 0644]

index 3401b43..c514e81 100644 (file)
@@ -4,8 +4,8 @@ import static java.util.Arrays.asList;
 
 import net.pterodactylus.rhynodge.Watcher;
 import net.pterodactylus.rhynodge.filters.HtmlFilter;
+import net.pterodactylus.rhynodge.filters.webpages.savoy.SavoyMerger;
 import net.pterodactylus.rhynodge.filters.webpages.savoy.SavoyTicketsFilter;
-import net.pterodactylus.rhynodge.mergers.LastStateMerger;
 import net.pterodactylus.rhynodge.queries.HttpQuery;
 
 /**
@@ -22,7 +22,7 @@ public class SavoyTicketWatcher extends DefaultWatcher {
                                                new HtmlFilter(),
                                                new SavoyTicketsFilter()
                                ),
-                               new LastStateMerger()
+                               new SavoyMerger()
                );
        }
 
diff --git a/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieState.kt b/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieState.kt
new file mode 100644 (file)
index 0000000..ea8f87c
--- /dev/null
@@ -0,0 +1,120 @@
+package net.pterodactylus.rhynodge.filters.webpages.savoy
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import kotlinx.html.a
+import kotlinx.html.body
+import kotlinx.html.div
+import kotlinx.html.dom.serialize
+import kotlinx.html.head
+import kotlinx.html.html
+import kotlinx.html.img
+import kotlinx.html.li
+import kotlinx.html.ol
+import kotlinx.html.section
+import kotlinx.html.span
+import kotlinx.html.style
+import kotlinx.html.title
+import kotlinx.html.ul
+import net.pterodactylus.rhynodge.Reaction
+import net.pterodactylus.rhynodge.states.AbstractState
+import java.time.LocalDateTime
+import java.util.Locale
+
+class MovieState(@JsonProperty val movies: Collection<Movie>, val newMovies: Collection<Movie> = emptySet(), private val triggered: Boolean = false) : AbstractState() {
+
+       private constructor() : this(emptySet())
+
+       override fun summary(reaction: Reaction) =
+               "%s â€“ Programme for %tY-%<tm-%<td".format(reaction.name(), earliestMovie?.earliestPerformance)
+
+       override fun plainText() = ""
+
+       override fun htmlText() = kotlinx.html.dom.createHTMLDocument().html {
+               head {
+                       title { +"Savoy Programme" }
+                       style("text/css") {
+                               +"html { font-family: Roboto; }"
+                               +"section.new-movies > .label { font-family: Impact; background-color: black; padding: 0.5ex; font-size: 200%; color: #ffffee; font-weight: bold; }"
+                               +"section.new-movies ul { padding: 0; margin: 0; }"
+                               +"section.new-movies li.movie { list-style: none; width: 250px; height: 353px; display: inline-block; position: relative; margin: 1ex 1ex 1ex 0ex; }"
+                               +"section.new-movies li.movie img { width: 100%; height: 100%; display: block; position: absolute; z-index: -1; }"
+                               +"section.new-movies li.movie .text { color: white; text-shadow: 2px 2px black; font-weight: bold; font-size: 150%; display: flex; flex-direction: column; justify-content: end; width: 100%; height: 100%; }"
+                               +"section.new-movies li.movie .text .name { padding: 1ex; font-size: 120%; }"
+
+                               +"section.daily-programmes ol { padding: 0; }"
+                               +"section.daily-programmes li { list-style: none; }"
+                               +"section.daily-programmes li.day > .label { font-family: Impact; background-color: black; padding: 0.5ex; font-size: 200%; color: #ffffee; font-weight: bold; }"
+                               +"section.daily-programmes li .performances { display: flex; border-top: 1ex; }"
+                               +"section.daily-programmes li.performance { width: 250px; height: 353px; display: inline-block; margin: 1ex 1ex 1ex 0ex; position: relative; }"
+                               +"section.daily-programmes li.performance a { width: 100%; height: 100%; display: block; text-decoration: none; }"
+                               +"section.daily-programmes li.performance a img { width: 100%; height: 100%; display: block; position: absolute; z-index: -1; }"
+                               +"section.daily-programmes li.performance a .text { color: white; text-shadow: 2px 2px black; font-weight: bold; font-size: 150%; display: flex; flex-direction: column; justify-content: space-between; width: 100%; height: 100%; }"
+                               +"section.daily-programmes li.performance a .text .time { padding: 1ex; text-align: right; }"
+                               +"section.daily-programmes li.performance a .text .name { padding: 1ex; font-size: 120%; }"
+                       }
+               }
+               body {
+                       if (newMovies.isNotEmpty()) {
+                               section("new-movies") {
+                                       div("label") {
+                                               +"New Movies"
+                                       }
+                                       ul {
+                                               newMovies.forEach { movie ->
+                                                       li("movie") {
+                                                               img(src = movie.imageUrl)
+                                                               div("text") {
+                                                                       div("name") {
+                                                                               +(movie.name)
+                                                                       }
+                                                               }
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+
+                       section("daily-programmes") {
+
+                               ol("days") {
+                                       movies.flatMap { it.performances.map(Performance::getTime).map(LocalDateTime::toLocalDate) }.distinct().sorted().forEach { date ->
+                                               li("day") {
+                                                       attributes += "data-date" to "%tY-%<tm-%<td".format(date)
+                                                       div("label") {
+                                                               +("Programme for %tA, %<tY-%<tm-%<td".format(Locale.ENGLISH, date))
+                                                       }
+                                                       ol("performances") {
+                                                               movies
+                                                                       .flatMap { movie -> movie.performances.map { movie to it } }
+                                                                       .filter { (movie, performances) -> performances.time.toLocalDate() == date }
+                                                                       .sortedBy { (_, performance) -> performance.time }
+                                                                       .forEach { (movie, performance) ->
+                                                                               li("performance") {
+                                                                                       a(href = performance.link) {
+                                                                                               img(src = movie.imageUrl)
+                                                                                               div("text") {
+                                                                                                       div("time") {
+                                                                                                               +("%tH:%<tM".format(performance.time))
+                                                                                                       }
+                                                                                                       div("name") {
+                                                                                                               +(movie.name)
+                                                                                                       }
+                                                                                               }
+                                                                                       }
+                                                                               }
+                                                                       }
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+               }
+
+       }.serialize()
+
+       override fun triggered() = newMovies.isNotEmpty() || triggered
+
+       private val earliestMovie = movies.minByOrNull { it.earliestPerformance ?: LocalDateTime.MAX }
+       private val Movie.earliestPerformance: LocalDateTime? get() = performances.minOfOrNull(Performance::getTime)
+
+}
diff --git a/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/SavoyMerger.kt b/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/SavoyMerger.kt
new file mode 100644 (file)
index 0000000..5406b17
--- /dev/null
@@ -0,0 +1,29 @@
+package net.pterodactylus.rhynodge.filters.webpages.savoy
+
+import net.pterodactylus.rhynodge.Merger
+import net.pterodactylus.rhynodge.State
+import org.apache.log4j.Logger
+
+class SavoyMerger : Merger {
+
+       override fun mergeStates(previousState: State, currentState: State): State {
+               previousState as? MovieState ?: throw IllegalArgumentException("previousState is not a MovieState")
+               currentState as? MovieState ?: throw IllegalArgumentException("currentState is not a MovieState")
+               logger.debug("previousState: $previousState")
+               logger.debug("currentState: $currentState")
+
+               val newMovies = currentState.movies
+                       .filter { it.name !in previousState.movies.map(Movie::getName) }
+               logger.debug("newMovies: $newMovies")
+
+               val hasChangedPerformances = newMovies.isNotEmpty() ||
+                               previousState.movies.map { movie -> movie.name to movie.performances.map { performance -> performance.time.toLocalDate() }.distinct().sorted() }.sortedBy(Pair<String, *>::first) !=
+                               currentState.movies.map { movie -> movie.name to movie.performances.map { performance -> performance.time.toLocalDate() }.distinct().sorted() }.sortedBy(Pair<String, *>::first)
+               logger.debug("hasChangedPerformances: $hasChangedPerformances")
+
+               return MovieState(currentState.movies, newMovies, hasChangedPerformances)
+       }
+
+}
+
+private val logger = Logger.getLogger(SavoyMerger::class.java)
diff --git a/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieStateTest.kt b/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieStateTest.kt
new file mode 100644 (file)
index 0000000..54a6dde
--- /dev/null
@@ -0,0 +1,123 @@
+package net.pterodactylus.rhynodge.filters.webpages.savoy
+
+import net.pterodactylus.rhynodge.Reaction
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.contains
+import org.hamcrest.Matchers.containsInAnyOrder
+import org.hamcrest.Matchers.containsString
+import org.hamcrest.Matchers.empty
+import org.hamcrest.Matchers.equalTo
+import org.hamcrest.Matchers.not
+import org.jsoup.Jsoup
+import org.jsoup.nodes.TextNode
+import org.junit.Test
+import java.time.LocalDateTime
+
+class MovieStateTest {
+
+       @Test
+       fun `summary contains date of earliest movie`() {
+               val movieState = MovieState(
+                       setOf(
+                               Movie("1", "").apply { addPerformance(Performance(LocalDateTime.of(2024, 2, 12, 18, 45), "")) },
+                               Movie("2", "").apply { addPerformance(Performance(LocalDateTime.of(2024, 3, 12, 15, 30), "")) },
+                               Movie("3", "").apply { addPerformance(Performance(LocalDateTime.of(2024, 2, 11, 21, 15), "")) }
+                       ), emptySet()
+               )
+               assertThat(movieState.output(Reaction("", null, null, null)).summary(), containsString("2024-02-11"))
+       }
+
+       @Test
+       fun `movie state without new movies is not triggered`() {
+               assertThat(MovieState(emptySet(), emptySet()).triggered(), equalTo(false))
+       }
+
+       @Test
+       fun `movie state with a new movie is triggered`() {
+               assertThat(MovieState(emptySet(), setOf(Movie("1", ""))).triggered(), equalTo(true))
+       }
+
+       @Test
+       fun `html output does not contain a section for new movies if there are no new movies`() {
+               val movieState = MovieState(emptySet(), emptySet())
+               val output = movieState.output(Reaction("", null, null, null))
+               val document = Jsoup.parse(output.text("text/html"))
+               assertThat(document.select("section.new-movies"), empty())
+       }
+
+       @Test
+       fun `html output does contain a section for new movie if there are new movies`() {
+               val movieState = MovieState(emptySet(), setOf(Movie("1", ""), Movie("2", "")))
+               val output = movieState.output(Reaction("", null, null, null))
+               val document = Jsoup.parse(output.text("text/html"))
+               assertThat(document.select("section.new-movies"), not(empty()))
+       }
+
+       @Test
+       fun `new movies section contains the titles of all new movies`() {
+               val movieState = MovieState(emptySet(), setOf(Movie("New Movie", ""), Movie("Even Newer Movie", "")))
+               val output = movieState.output(Reaction("", null, null, null))
+               val document = Jsoup.parse(output.text("text/html"))
+               assertThat(document.select("section.new-movies li.movie .name").textNodes().map(TextNode::text), containsInAnyOrder("New Movie", "Even Newer Movie"))
+       }
+
+       @Test
+       fun `html output contains section for the daily programme`() {
+               val movieState = MovieState(emptySet(), emptySet())
+               val output = movieState.output(Reaction("", null, null, null))
+               val document = Jsoup.parse(output.text("text/html"))
+               assertThat(document.select("section.daily-programmes"), not(empty()))
+       }
+
+       @Test
+       fun `html output contains a section for each day with a movie`() {
+               val movieState = MovieState(
+                       setOf(
+                               movie("Movie 1", "", "20240212-1845", "20240213-1330", "20240214-1815"),
+                               movie("Movie 2", "", "20240212-2030", "20240213-1745", "20240214-1430"),
+                               movie("Movie 3", "", "20240213-2015", "20240214-1730"),
+                               movie("Movie 4", "", "20240216-1000"),
+                       ), emptySet()
+               )
+               val output = movieState.output(Reaction("", null, null, null))
+               val html = output.text("text/html")
+               val document = Jsoup.parse(html)
+               assertThat(document.select("section.daily-programmes li.day").map { it.attr("data-date") }, contains("2024-02-12", "2024-02-13", "2024-02-14", "2024-02-16"))
+       }
+
+       @Test
+       fun `html output contains the correct movies within each day`() {
+               val movieState = MovieState(
+                       setOf(
+                               movie("Movie 1", "https://cdn.premiumkino.de/movie/3047/81c49774d7828a898ae1d525ffd135af_w300.jpg", "20240212-1845", "20240213-1330", "20240214-1815"),
+                               movie("Movie 2", "https://cdn.premiumkino.de/movie/1066/aba09af737677ff6a15676ae588098b1_w300.jpg", "20240212-2030", "20240213-1745", "20240214-1430"),
+                               movie("Movie 3", "https://cdn.premiumkino.de/movie/7300/14d1b21dee51a82a7b096ca282bf01c8_w300.png", "20240213-2015", "20240214-1730"),
+                               movie("Movie 4", "https://cdn.premiumkino.de/movie/6080/cef2b33483d2898ddb472c955c58ea20_w300.jpg", "20240216-1000"),
+                       ), setOf(
+                               movie("Movie 1", "https://cdn.premiumkino.de/movie/3047/81c49774d7828a898ae1d525ffd135af_w300.jpg", "20240212-1845", "20240213-1330", "20240214-1815"),
+                               movie("Movie 2", "https://cdn.premiumkino.de/movie/1066/aba09af737677ff6a15676ae588098b1_w300.jpg", "20240212-1845", "20240213-1330", "20240214-1815")
+                       )
+               )
+               val output = movieState.output(Reaction("", null, null, null))
+               val html = output.text("text/html")
+               val document = Jsoup.parse(html)
+               assertThat(
+                       document.select("section.daily-programmes li.day[data-date='2024-02-12'] li.performance").map { it.select(".time").text() + " - " + it.select(".name").text() }, contains(
+                               "18:45 - Movie 1", "20:30 - Movie 2"
+                       )
+               )
+       }
+
+}
+
+private fun dateTime(dateTimeString: String) = LocalDateTime.of(
+       dateTimeString.substring(0..3).toInt(),
+       dateTimeString.substring(4..5).toInt(),
+       dateTimeString.substring(6..7).toInt(),
+       dateTimeString.substring(9..10).toInt(),
+       dateTimeString.substring(11..12).toInt(),
+)
+
+private fun movie(name: String, imageUrl: String, vararg times: String) = Movie(name, imageUrl).apply {
+       times.map(::dateTime).map { Performance(it, "https://link/$it") }.forEach(this::addPerformance)
+}
diff --git a/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/SavoyMergerTest.kt b/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/SavoyMergerTest.kt
new file mode 100644 (file)
index 0000000..d81c86f
--- /dev/null
@@ -0,0 +1,105 @@
+package net.pterodactylus.rhynodge.filters.webpages.savoy
+
+import net.pterodactylus.rhynodge.states.StateManagerTest.TestState
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.containsInAnyOrder
+import org.hamcrest.Matchers.empty
+import org.hamcrest.Matchers.equalTo
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import java.time.LocalDateTime
+
+class SavoyMergerTest {
+
+       @Test
+       fun `merger throws exception if first state is not a movie state`() {
+               assertThrows(IllegalArgumentException::class.java) { merger.mergeStates(TestState(), MovieState(emptySet())) }
+       }
+
+       @Test
+       fun `merger throws exception if second state is not a movie state`() {
+               assertThrows(IllegalArgumentException::class.java) { merger.mergeStates(MovieState(emptySet()), TestState()) }
+       }
+
+       @Test
+       fun `merging two empty states results in an empty state`() {
+               assertThat(merger.mergeStates(MovieState(emptySet()), MovieState(emptySet())).isEmpty, equalTo(true))
+       }
+
+       @Test
+       fun `merging a non-empty state into an empty state results in a state containing all movies`() {
+               val oldState = MovieState(emptySet())
+               val newState = MovieState(setOf(Movie("1", "")))
+               val mergedState = merger.mergeStates(oldState, newState) as MovieState
+               assertThat(mergedState.movies, containsInAnyOrder(Movie("1", "")))
+       }
+
+       @Test
+       fun `merging two states keeps only movies from current state`() {
+               val oldState = MovieState(setOf(Movie("1", ""), Movie("3", "")))
+               val newState = MovieState(setOf(Movie("2", ""), Movie("4", "")))
+               val mergedState = merger.mergeStates(oldState, newState) as MovieState
+               assertThat(mergedState.movies, containsInAnyOrder(Movie("2", ""), Movie("4", "")))
+       }
+
+       @Test
+       fun `merging a state with new movies identifies new movies`() {
+               val oldState = MovieState(setOf(Movie("1", ""), Movie("2", ""), Movie("3", "")))
+               val newState = MovieState(setOf(Movie("2", ""), Movie("3", ""), Movie("4", "")))
+               val mergedState = merger.mergeStates(oldState, newState) as MovieState
+               assertThat(mergedState.newMovies, containsInAnyOrder(Movie("4", "")))
+       }
+
+       @Test
+       fun `movies with different performances are still considered the same movie`() {
+               val oldState = MovieState(setOf(Movie("1", "").apply { addPerformance(Performance(LocalDateTime.of(2024, 2, 14, 18, 45), "")) }))
+               val newState = MovieState(setOf(Movie("1", "").apply { addPerformance(Performance(LocalDateTime.of(2024, 2, 15, 14, 30), "")) }))
+               val mergedState = merger.mergeStates(oldState, newState) as MovieState
+               assertThat(mergedState.newMovies, empty())
+       }
+
+       @Test
+       fun `merging states with movies starting the same day does not create a triggered state`() {
+               val oldState = MovieState(setOf(Movie("1", "").apply { addPerformance(Performance(LocalDateTime.of(2024, 2, 14, 18, 45), "")) }))
+               val newState = MovieState(setOf(Movie("1", "").apply { addPerformance(Performance(LocalDateTime.of(2024, 2, 14, 14, 30), "")) }))
+               val mergedState = merger.mergeStates(oldState, newState) as MovieState
+               assertThat(mergedState.triggered(), equalTo(false))
+       }
+
+       @Test
+       fun `merging states with movies starting on different days does create a triggered state`() {
+               val oldState = MovieState(setOf(Movie("1", "").apply { addPerformance(Performance(LocalDateTime.of(2024, 2, 14, 18, 45), "")) }))
+               val newState = MovieState(setOf(Movie("1", "").apply { addPerformance(Performance(LocalDateTime.of(2024, 2, 15, 14, 30), "")) }))
+               val mergedState = merger.mergeStates(oldState, newState) as MovieState
+               assertThat(mergedState.triggered(), equalTo(true))
+       }
+
+       @Test
+       fun `merging states where movies have different performances but still on the same day does not create a triggered state`() {
+               val oldState = MovieState(setOf(Movie("1", "").apply {
+                       addPerformance(Performance(LocalDateTime.of(2024, 2, 14, 14, 30), ""))
+                       addPerformance(Performance(LocalDateTime.of(2024, 2, 14, 18, 45), ""))
+               }))
+               val newState = MovieState(setOf(Movie("1", "").apply {
+                       addPerformance(Performance(LocalDateTime.of(2024, 2, 14, 18, 45), ""))
+               }))
+               val mergedState = merger.mergeStates(oldState, newState) as MovieState
+               assertThat(mergedState.triggered(), equalTo(false))
+       }
+
+       @Test
+       fun `merging states where movies have different performances and are not on the same day anymore does create a triggered state`() {
+               val oldState = MovieState(setOf(Movie("1", "").apply {
+                       addPerformance(Performance(LocalDateTime.of(2024, 2, 14, 18, 45), ""))
+                       addPerformance(Performance(LocalDateTime.of(2024, 2, 15, 14, 30), ""))
+               }))
+               val newState = MovieState(setOf(Movie("1", "").apply {
+                       addPerformance(Performance(LocalDateTime.of(2024, 2, 15, 14, 30), ""))
+               }))
+               val mergedState = merger.mergeStates(oldState, newState) as MovieState
+               assertThat(mergedState.triggered(), equalTo(true))
+       }
+
+       private val merger = SavoyMerger()
+
+}