From 5e569e6252aedc0d5405beb4cea3e9f0d2557862 Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20=E2=80=98Bombe=E2=80=99=20Roden?= Date: Wed, 8 Oct 2025 15:07:13 +0200 Subject: [PATCH] =?utf8?q?=E2=9C=85=20Add=20test=20for=20ComicState?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- .../pterodactylus/rhynodge/states/ComicState.java | 16 +- .../rhynodge/states/ComicStateTest.kt | 192 +++++++++++++++++++++ .../net/pterodactylus/util/test/NodeMatchers.kt | 61 +++++++ 3 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/net/pterodactylus/rhynodge/states/ComicStateTest.kt diff --git a/src/main/java/net/pterodactylus/rhynodge/states/ComicState.java b/src/main/java/net/pterodactylus/rhynodge/states/ComicState.java index 96dae22..117f5b6 100644 --- a/src/main/java/net/pterodactylus/rhynodge/states/ComicState.java +++ b/src/main/java/net/pterodactylus/rhynodge/states/ComicState.java @@ -97,7 +97,9 @@ public class ComicState extends AbstractState implements Iterable { protected String plainText() { StringBuilder text = new StringBuilder(); - for (Comic newComic : newComics) { + List 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 { StringBuilder html = new StringBuilder(); html.append(""); - for (Comic newComic : newComics) { + List latestComics = new ArrayList<>(comics()); + Collections.reverse(latestComics); + + for (Comic newComic : latestComics.stream().filter(newComics::contains).toList()) { generateComicHtml(html, newComic); } - List 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 { } private void generateComicHtml(StringBuilder html, Comic comic) { - html.append("

").append(StringEscapeUtils.escapeHtml4(comic.title())).append("

\n"); + html.append("
").append("

").append(StringEscapeUtils.escapeHtml4(comic.title())).append("

\n"); for (Strip strip : comic) { html.append("
\"").append(StringEscapeUtils.escapeHtml4(strip.comment()));
\n"); + html.append("\" />
\n"); html.append("
").append(StringEscapeUtils.escapeHtml4(strip.comment())).append("
\n"); } + html.append(""); } /** 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 index 0000000..772904c --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/rhynodge/states/ComicStateTest.kt @@ -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) diff --git a/src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt b/src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt index 58f1b5b..2b7176c 100644 --- a/src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt +++ b/src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt @@ -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() { 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) = object : TypeSafeDiagnosingMatcher() { + 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 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() { + 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) + } +} -- 2.7.4