✅ Add test for ComicState
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Wed, 8 Oct 2025 13:07:13 +0000 (15:07 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Wed, 8 Oct 2025 15:55:31 +0000 (17:55 +0200)
src/main/java/net/pterodactylus/rhynodge/states/ComicState.java
src/test/kotlin/net/pterodactylus/rhynodge/states/ComicStateTest.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt

index 96dae22..117f5b6 100644 (file)
@@ -97,7 +97,9 @@ public class ComicState extends AbstractState implements Iterable<Comic> {
        protected String plainText() {
                StringBuilder text = new StringBuilder();
 
-               for (Comic newComic : newComics) {
+               List<Comic> comicsToShow = new ArrayList<>(comics.stream().filter(newComics::contains).toList());
+               Collections.reverse(comicsToShow);
+               for (Comic newComic : comicsToShow) {
                        text.append("Comic Found: ").append(newComic.title()).append("\n\n");
                        for (Strip strip : newComic) {
                                text.append("Image: ").append(strip.imageUrl()).append("\n");
@@ -117,12 +119,13 @@ public class ComicState extends AbstractState implements Iterable<Comic> {
                StringBuilder html = new StringBuilder();
                html.append("<body>");
 
-               for (Comic newComic : newComics) {
+               List<Comic> latestComics = new ArrayList<>(comics());
+               Collections.reverse(latestComics);
+
+               for (Comic newComic : latestComics.stream().filter(newComics::contains).toList()) {
                        generateComicHtml(html, newComic);
                }
 
-               List<Comic> latestComics = new ArrayList<>(comics());
-               Collections.reverse(latestComics);
                int comicCount = 0;
                for (Comic comic : latestComics) {
                        if (newComics.contains(comic)) {
@@ -138,14 +141,15 @@ public class ComicState extends AbstractState implements Iterable<Comic> {
        }
 
        private void generateComicHtml(StringBuilder html, Comic comic) {
-               html.append("<h1>").append(StringEscapeUtils.escapeHtml4(comic.title())).append("</h1>\n");
+               html.append("<div>").append("<h1>").append(StringEscapeUtils.escapeHtml4(comic.title())).append("</h1>\n");
                for (Strip strip : comic) {
                        html.append("<div><img src=\"").append(StringEscapeUtils.escapeHtml4(strip.imageUrl()));
                        html.append("\" alt=\"").append(StringEscapeUtils.escapeHtml4(strip.comment()));
                        html.append("\" title=\"").append(StringEscapeUtils.escapeHtml4(strip.comment()));
-                       html.append("\"></div>\n");
+                       html.append("\" /></div>\n");
                        html.append("<div>").append(StringEscapeUtils.escapeHtml4(strip.comment())).append("</div>\n");
                }
+               html.append("</div>");
        }
 
        /**
diff --git a/src/test/kotlin/net/pterodactylus/rhynodge/states/ComicStateTest.kt b/src/test/kotlin/net/pterodactylus/rhynodge/states/ComicStateTest.kt
new file mode 100644 (file)
index 0000000..772904c
--- /dev/null
@@ -0,0 +1,192 @@
+package net.pterodactylus.rhynodge.states
+
+import java.io.StringReader
+import javax.xml.parsers.DocumentBuilderFactory
+import net.pterodactylus.rhynodge.Reaction
+import net.pterodactylus.rhynodge.states.ComicState.Comic
+import net.pterodactylus.rhynodge.states.ComicState.Strip
+import net.pterodactylus.util.dom.XPathEvaluator
+import net.pterodactylus.util.test.containsNode
+import net.pterodactylus.util.test.isImageNode
+import net.pterodactylus.util.test.isTextNode
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.allOf
+import org.hamcrest.Matchers.contains
+import org.hamcrest.Matchers.containsInAnyOrder
+import org.hamcrest.Matchers.containsString
+import org.hamcrest.Matchers.equalTo
+import org.hamcrest.Matchers.not
+import org.junit.jupiter.api.Test
+import org.xml.sax.InputSource
+
+class ComicStateTest {
+
+       @Test
+       fun `can create comic state`() {
+               ComicState(emptySet())
+       }
+
+       @Test
+       fun `comic state is empty if list of comics is empty`() {
+               assertThat(ComicState(emptyList()).isEmpty, equalTo(true))
+       }
+
+       @Test
+       fun `comic state is not empty if list of comics contains a comic`() {
+               assertThat(ComicState(listOf(Comic(""))).isEmpty, equalTo(false))
+       }
+
+       @Test
+       fun `comic state without list of new comics is not triggered`() {
+               assertThat(ComicState(emptyList()).triggered(), equalTo(false))
+       }
+
+       @Test
+       fun `comic state with empty list of new comics is not triggered`() {
+               assertThat(ComicState(emptyList(), emptyList()).triggered(), equalTo(false))
+       }
+
+       @Test
+       fun `comic state with new comics is triggered`() {
+               assertThat(ComicState(emptyList(), listOf(Comic(""))).triggered(), equalTo(true))
+       }
+
+       @Test
+       fun `summary contains reaction's name`() {
+               assertThat(ComicState(emptyList()).summary(reaction), containsString("foo"))
+       }
+
+       @Test
+       fun `output's summary contains reaction's name`() {
+               val output = ComicState(emptyList()).output(reaction)
+               assertThat(output.summary(), containsString("foo"))
+       }
+
+       @Test
+       fun `output's text output contains only new comics`() {
+               val newComics = listOf(
+                       Comic("New 1"),
+                       Comic("New 2"),
+               )
+               val output = ComicState(newComics.plusElement(Comic("Old 1")), newComics).output(reaction)
+               assertThat(output.text("text/plain"), allOf(
+                       containsString("Comic Found: New 1"),
+                       containsString("Comic Found: New 2"),
+                       not(containsString("Comic Found: Old 1")),
+               ))
+       }
+
+       @Test
+       fun `output's plain text contains all strips of comic`() {
+               val comics = listOf(
+                       Comic("New 1").apply {
+                               add(Strip("u1", "c1"))
+                       },
+                       Comic("New 2").apply {
+                               add(Strip("u2.1", "c2.1"))
+                               add(Strip("u2.2", "c2.2"))
+                       }
+               )
+               val output = ComicState(comics, comics).output(reaction)
+               assertThat(output.text("text/plain"), allOf(
+                       containsString("Comic Found: New 1\n\nImage: u1\nComment: c1"),
+                       containsString("Comic Found: New 2\n\nImage: u2.1\nComment: c2.1\nImage: u2.2\nComment: c2.2"),
+               ))
+       }
+
+       @Test
+       fun `output's plain text contains comics in correct order`() {
+               val comics = (1..9).map { index -> Comic("Comic $index").apply { add(Strip("u$index", "c$index")) } }
+               val output = ComicState(comics, comics).output(reaction)
+               val positions = (1..9).map { index -> output.text("text/plain").indexOf("Comic $index") }
+               assertThat(positions, contains(*positions.sortedDescending().toTypedArray()))
+       }
+
+       @Test
+       fun `comment is omitted if blank`() {
+               val comic = Comic("Comic 2").apply {
+                       add(Strip("u2.1", "   "))
+                       add(Strip("u2.2", "c2.2"))
+               }
+               val output = ComicState(listOf(comic), listOf(comic)).output(reaction)
+               assertThat(output.text("text/plain"), allOf(
+                       containsString("Comic Found: Comic 2\n\nImage: u2.1\nImage: u2.2\nComment: c2.2"),
+               ))
+       }
+
+       @Test
+       fun `comic is rendered correctly in output's html`() {
+               val comic = Comic("Comic 1").apply {
+                       add(Strip("u1.1", "c1.1"))
+                       add(Strip("u1.2", "c1.2"))
+               }
+               renderHtmlAndVerify(ComicState(listOf(comic), listOf(comic))) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("(//body//h1)[1]/text()|(//body//h1)[1]/following-sibling::div"), contains(
+                               isTextNode("Comic 1"), containsNode(isImageNode("u1.1", "c1.1", "c1.1")), isTextNode("c1.1"), containsNode(isImageNode("u1.2", "c1.2", "c1.2")), isTextNode("c1.2"),
+                       ))
+               }
+       }
+
+       @Test
+       fun `output's html contains all new comics`() {
+               val comics = (1..9).map { index -> Comic("Comic $index").apply { add(Strip("u$index", "c$index")) } }
+               renderHtmlAndVerify(ComicState(comics, comics)) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("//body//h1/text()"), containsInAnyOrder(
+                               (1..9).map { index -> "Comic $index" }.map(::isTextNode)
+                       ))
+               }
+       }
+
+       @Test
+       fun `output's html contains comics in reverse order`() {
+               val comics = (1..7).map { index -> Comic("Comic $index").apply { add(Strip("u$index", "c$index")) } }
+               renderHtmlAndVerify(ComicState(comics, comics)) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("//body//h1/text()"), contains(
+                               (7 downTo 1).map { index -> "Comic $index" }.map(::isTextNode)
+                       ))
+               }
+       }
+
+       @Test
+       fun `output's html contains new comics before old comics`() {
+               val comics = (1..6).map { index -> Comic("Comic $index").apply { add(Strip("u$index", "c$index")) } }
+               val newComics = (1..3).map { index -> Comic("Comic $index").apply { add(Strip("u$index", "c$index")) } }
+               renderHtmlAndVerify(ComicState(comics, newComics)) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("//body//h1/text()").subList(0, 3), containsInAnyOrder(
+                               (1..3).map { index -> "Comic $index" }.map(::isTextNode)
+                       ))
+                       assertThat(xPathEvaluator.asNodeList("//body//h1/text()").subList(3, 6), containsInAnyOrder(
+                               (4..6).map { index -> "Comic $index" }.map(::isTextNode)
+                       ))
+               }
+       }
+
+       @Test
+       fun `output's html contains new comics in list order`() {
+               val comics = (1..9).map { index -> Comic("Comic $index").apply { add(Strip("u$index", "c$index")) } }
+               renderHtmlAndVerify(ComicState(comics, comics.subList(7, 9))) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("(//body//h1)[position()=1 or position()=2]/text()"), contains(
+                               (9 downTo 8).map { index -> "Comic $index" }.map(::isTextNode)
+                       ))
+               }
+       }
+
+       @Test
+       fun `output's html contains new comics plus at most seven old ones`() {
+               val comics = (1..9).map { index -> Comic("Comic $index").apply { add(Strip("u$index", "c$index")) } }
+               renderHtmlAndVerify(ComicState(comics, listOf(comics.last()))) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("//body//h1/text()"), contains(
+                               (9 downTo 2).map { index -> "Comic $index" }.map(::isTextNode)
+                       ))
+               }
+       }
+
+       private fun renderHtmlAndVerify(comicState: ComicState, xPathEvaluator: (xPathEvaluator: XPathEvaluator) -> Unit) {
+               val output = comicState.output(reaction)
+               val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(InputSource(StringReader(output.text("text/html"))))
+               xPathEvaluator(XPathEvaluator(document))
+       }
+
+}
+
+private val reaction = Reaction("foo", null, null, null)
index 58f1b5b..2b7176c 100644 (file)
@@ -1,6 +1,8 @@
 package net.pterodactylus.util.test
 
+import net.pterodactylus.util.dom.toList
 import org.hamcrest.Description
+import org.hamcrest.Matcher
 import org.hamcrest.TypeSafeDiagnosingMatcher
 import org.w3c.dom.Node
 
@@ -60,3 +62,62 @@ fun isTextNode(text: String) = object : TypeSafeDiagnosingMatcher<Node>() {
                description.appendText("text node with text ").appendValue(text)
        }
 }
+
+/**
+ * Creates a Hamcrest [matcher][Matcher] that matches [nodes][Node] based on
+ * whether they contain a singular element node matching the given
+ * [nodeMatcher].
+ *
+ * @param[nodeMatcher] The matcher for the contained node
+ * @return A [matcher][Matcher] for [nodes][Node]
+ */
+fun containsNode(nodeMatcher: Matcher<Node>) = object : TypeSafeDiagnosingMatcher<Node>() {
+       override fun matchesSafely(node: Node, mismatchDescription: Description): Boolean {
+               val elementNode = node.childNodes.toList().singleOrNull { it.nodeType == Node.ELEMENT_NODE }
+               if (elementNode == null) {
+                       mismatchDescription.appendText("no node found")
+                       return false;
+               }
+               if (!nodeMatcher.matches(elementNode)) {
+                       mismatchDescription.appendText("contained node was ");
+                       nodeMatcher.describeMismatch(elementNode, mismatchDescription)
+                       return false
+               }
+               return true
+       }
+
+       override fun describeTo(description: Description) {
+               description.appendText("node containing ").appendDescriptionOf(nodeMatcher)
+       }
+}
+
+/**
+ * Creates a Hamcrest [matcher][Matcher] that matches HTML <img> nodes.
+ *
+ * @param[url] The source URL of the image tag
+ * @param[title] The title attribute of the image tag
+ * @param[alt] The alt attribute of the image tag
+ * @return A [matcher][Matcher] for [nodes][Node]
+ */
+fun isImageNode(url: String, title: String, alt: String) = object : TypeSafeDiagnosingMatcher<Node>() {
+       override fun matchesSafely(node: Node, mismatchDescription: Description) =
+               if ((node.nodeType != Node.ELEMENT_NODE) || (node.nodeName != "img")) {
+                       mismatchDescription.appendText("is not an image node")
+                       false
+               } else if (node.attributes.getNamedItem("src").nodeValue != url) {
+                       mismatchDescription.appendText("src is ").appendValue(node.attributes.getNamedItem("src").nodeValue)
+                       false
+               } else if (node.attributes.getNamedItem("title").nodeValue != title) {
+                       mismatchDescription.appendText("title is ").appendValue(node.attributes.getNamedItem("title").nodeValue)
+                       false
+               } else if (node.attributes.getNamedItem("alt").nodeValue != alt) {
+                       mismatchDescription.appendText("alt is ").appendValue(node.attributes.getNamedItem("alt").nodeValue)
+                       false
+               } else {
+                       true
+               }
+
+       override fun describeTo(description: Description) {
+               description.appendText("image node with src ").appendValue(url).appendText(" and title ").appendValue(title).appendText(" and alt text ").appendValue(alt)
+       }
+}