From b66df27edfad2a27e8602636a81941600db5d558 Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20=E2=80=98Bombe=E2=80=99=20Roden?= Date: Sun, 25 Feb 2024 01:54:01 +0100 Subject: [PATCH] =?utf8?q?=F0=9F=9A=B8=20Improve=20mail=20with=20movies=20?= =?utf8?q?from=20the=20Savoy?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- .../rhynodge/watchers/SavoyTicketWatcher.java | 4 +- .../rhynodge/filters/webpages/savoy/MovieState.kt | 120 ++++++++++++++++++++ .../rhynodge/filters/webpages/savoy/SavoyMerger.kt | 29 +++++ .../filters/webpages/savoy/MovieStateTest.kt | 123 +++++++++++++++++++++ .../filters/webpages/savoy/SavoyMergerTest.kt | 105 ++++++++++++++++++ 5 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieState.kt create mode 100644 src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/SavoyMerger.kt create mode 100644 src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieStateTest.kt create mode 100644 src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/SavoyMergerTest.kt diff --git a/src/main/java/net/pterodactylus/rhynodge/watchers/SavoyTicketWatcher.java b/src/main/java/net/pterodactylus/rhynodge/watchers/SavoyTicketWatcher.java index 3401b43..c514e81 100644 --- a/src/main/java/net/pterodactylus/rhynodge/watchers/SavoyTicketWatcher.java +++ b/src/main/java/net/pterodactylus/rhynodge/watchers/SavoyTicketWatcher.java @@ -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 index 0000000..ea8f87c --- /dev/null +++ b/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieState.kt @@ -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, val newMovies: Collection = emptySet(), private val triggered: Boolean = false) : AbstractState() { + + private constructor() : this(emptySet()) + + override fun summary(reaction: Reaction) = + "%s – Programme for %tY-% .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-% 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:% movie.name to movie.performances.map { performance -> performance.time.toLocalDate() }.distinct().sorted() }.sortedBy(Pair::first) != + currentState.movies.map { movie -> movie.name to movie.performances.map { performance -> performance.time.toLocalDate() }.distinct().sorted() }.sortedBy(Pair::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 index 0000000..54a6dde --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/MovieStateTest.kt @@ -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 index 0000000..d81c86f --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/savoy/SavoyMergerTest.kt @@ -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() + +} -- 2.7.4