✅ Add test for EpisodeState
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 7 Oct 2025 09:21:22 +0000 (11:21 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Wed, 8 Oct 2025 13:08:11 +0000 (15:08 +0200)
src/main/java/net/pterodactylus/rhynodge/states/EpisodeState.java
src/test/kotlin/net/pterodactylus/rhynodge/states/EpisodeStateTest.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/util/collection/Lists.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/util/dom/NodeLists.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/util/dom/XPathEvaluator.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt [new file with mode: 0644]

index d58870a..4520c8c 100644 (file)
@@ -206,7 +206,7 @@ public class EpisodeState extends AbstractState implements Iterable<Episode> {
                                        } else {
                                                htmlBuilder.append("<td colspan=\"2\"></td>");
                                        }
-                                       htmlBuilder.append("<td>").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append("</td>");
+                                       htmlBuilder.append("<td class='filename'>").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append("</td>");
                                        htmlBuilder.append("<td>").append(StringEscapeUtils.escapeHtml4(torrentFile.size())).append("</td>");
                                        htmlBuilder.append("<td>").append(torrentFile.fileCount()).append("</td>");
                                        htmlBuilder.append("<td>").append(torrentFile.seedCount()).append("</td>");
diff --git a/src/test/kotlin/net/pterodactylus/rhynodge/states/EpisodeStateTest.kt b/src/test/kotlin/net/pterodactylus/rhynodge/states/EpisodeStateTest.kt
new file mode 100644 (file)
index 0000000..a30f0ca
--- /dev/null
@@ -0,0 +1,153 @@
+package net.pterodactylus.rhynodge.states
+
+import java.io.StringReader
+import javax.xml.parsers.DocumentBuilderFactory
+import net.pterodactylus.rhynodge.states.EpisodeState.Episode
+import net.pterodactylus.rhynodge.states.TorrentState.TorrentFile
+import net.pterodactylus.util.collection.batch
+import net.pterodactylus.util.dom.XPathEvaluator
+import net.pterodactylus.util.test.isLinkNode
+import net.pterodactylus.util.test.isTextNode
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.anything
+import org.hamcrest.Matchers.contains
+import org.hamcrest.Matchers.containsInAnyOrder
+import org.hamcrest.Matchers.equalTo
+import org.junit.jupiter.api.Test
+import org.xml.sax.InputSource
+
+class EpisodeStateTest {
+
+       @Test
+       fun `episode state renders table with correct caption`() {
+               renderHtmlAndVerify(episodeState) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asString("/html/body/table/caption/text()"), equalTo("All Known Episodes"))
+               }
+       }
+
+       @Test
+       fun `episode state renders table with correct headers`() {
+               renderHtmlAndVerify(episodeState) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("/html/body/table/thead/tr/th/text()"), contains(
+                               isTextNode("Season"),
+                               isTextNode("Episode"),
+                               isTextNode("Filename"),
+                               isTextNode("Size"),
+                               isTextNode("File(s)"),
+                               isTextNode("Seeds"),
+                               isTextNode("Leechers"),
+                               isTextNode("Magnet"),
+                               isTextNode("Download")
+                       ))
+               }
+       }
+
+       @Test
+       fun `episode state renders one row per torrent file`() {
+               renderHtmlAndVerify(episodeState) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNumber("count(/html/body/table/tbody/tr)"), equalTo(6.0))
+               }
+       }
+
+       @Test
+       fun `episode state renders all episodes in correct order`() {
+               renderHtmlAndVerify(episodeState) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("/html/body/table/tbody/tr/td[position()=1 or position()=2]"), contains(
+                               isTextNode("2"), isTextNode("1"),
+                               isTextNode("1"), isTextNode("2"),
+                               isTextNode(""), anything(),
+                               isTextNode("1"), isTextNode("1"),
+                               isTextNode(""), anything(),
+                               isTextNode(""), anything(),
+                       ))
+               }
+       }
+
+       @Test
+       fun `files are sorted in correct order`() {
+               renderHtmlAndVerify(episodeState) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("/html/body/table/tbody/tr/td[@class='filename']"), contains(
+                               isTextNode("S02E01.720p.mkv"),
+                               isTextNode("S01E02.720p.mkv"),
+                               isTextNode("S01E02.1080p.mkv"),
+                               isTextNode("S01E01.720p.mkv"),
+                               isTextNode("S01E01.1080p.mkv"),
+                               isTextNode("1x01.1080p.mkv"),
+                       ))
+               }
+       }
+
+       @Test
+       fun `files are rendered correctly`() {
+               renderHtmlAndVerify(episodeState) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asNodeList("/html/body/table/tbody/tr/td[@class='filename']/text() | /html/body/table/tbody/tr/td[@class='filename']/following-sibling::td/child::node()[1]").batch(7), containsInAnyOrder(
+                               contains(isTextNode("S01E01.720p.mkv"), isTextNode("111111111"), isTextNode("11"), isTextNode("111"), isTextNode("1111"), isLinkNode("Link", "magnet:S01E01.720p.mkv"), isLinkNode("Link", "url://S01E01.720p.mkv")),
+                               contains(isTextNode("S01E01.1080p.mkv"), isTextNode("222222222"), isTextNode("22"), isTextNode("222"), isTextNode("2222"), isLinkNode("Link", "magnet:S01E01.1080p.mkv"), isLinkNode("Link", "url://S01E01.1080p.mkv")),
+                               contains(isTextNode("1x01.1080p.mkv"), isTextNode("333333333"), isTextNode("33"), isTextNode("333"), isTextNode("3333"), isLinkNode("Link", "magnet:1x01.1080p.mkv"), isLinkNode("Link", "url://1x01.1080p.mkv")),
+                               contains(isTextNode("S01E02.720p.mkv"), isTextNode("444444444"), isTextNode("44"), isTextNode("444"), isTextNode("4444"), isLinkNode("Link", "magnet:S01E02.720p.mkv"), isLinkNode("Link", "url://S01E02.720p.mkv")),
+                               contains(isTextNode("S01E02.1080p.mkv"), isTextNode("555555555"), isTextNode("55"), isTextNode("555"), isTextNode("5555"), isLinkNode("Link", "magnet:S01E02.1080p.mkv"), isLinkNode("Link", "url://S01E02.1080p.mkv")),
+                               contains(isTextNode("S02E01.720p.mkv"), isTextNode("666666666"), isTextNode("66"), isTextNode("666"), isTextNode("6666"), isLinkNode("Link", "magnet:S02E01.720p.mkv"), isLinkNode("Link", "url://S02E01.720p.mkv")),
+                       ))
+               }
+       }
+
+       @Test
+       fun `filename is escaped correctly`() {
+               renderHtmlAndVerify(EpisodeState(listOf(Episode(1, 1).apply { addTorrentFile(TorrentFile("A&B", "1", "m", "d", 2, 3, 4)) }))) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asString("/html/body/table/tbody/tr/td[@class='filename']/text()"), equalTo("A&B"))
+               }
+       }
+
+       @Test
+       fun `sizes is escaped correctly`() {
+               renderHtmlAndVerify(EpisodeState(listOf(Episode(1, 1).apply { addTorrentFile(TorrentFile("A", "&", "m", "d", 2, 3, 4)) }))) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asString("/html/body/table/tbody/tr/td[@class='filename']/following-sibling::td[1]/text()"), equalTo("&"))
+               }
+       }
+
+       @Test
+       fun `magnet link is escaped correctly`() {
+               renderHtmlAndVerify(EpisodeState(listOf(Episode(1, 1).apply { addTorrentFile(TorrentFile("A", "1", "&", "d", 2, 3, 4)) }))) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asString("/html/body/table/tbody/tr/td[@class='filename']/following-sibling::td[5]/a/@href"), equalTo("&"))
+               }
+       }
+
+       @Test
+       fun `download link is escaped correctly`() {
+               renderHtmlAndVerify(EpisodeState(listOf(Episode(1, 1).apply { addTorrentFile(TorrentFile("A", "1", "m", "&", 2, 3, 4)) }))) { xPathEvaluator ->
+                       assertThat(xPathEvaluator.asString("/html/body/table/tbody/tr/td[@class='filename']/following-sibling::td[6]/a/@href"), equalTo("&"))
+               }
+       }
+
+       private fun renderHtmlAndVerify(episodeState: EpisodeState, xPathEvaluator: (xPathEvaluator: XPathEvaluator) -> Unit) {
+               val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(InputSource(StringReader(episodeState.htmlText())))
+               xPathEvaluator(XPathEvaluator(document))
+       }
+
+       private val episodeState = EpisodeState(episodes, listOf(episodes[2]), listOf(episodes[1]), listOf(torrentFiles[2], torrentFiles[4], torrentFiles[5]))
+
+}
+
+private val torrentFiles = listOf(
+       TorrentFile("S01E01.720p.mkv", "111111111", "magnet:S01E01.720p.mkv", "url://S01E01.720p.mkv", 11, 111, 1111),
+       TorrentFile("S01E01.1080p.mkv", "222222222", "magnet:S01E01.1080p.mkv", "url://S01E01.1080p.mkv", 22, 222, 2222),
+       TorrentFile("1x01.1080p.mkv", "333333333", "magnet:1x01.1080p.mkv", "url://1x01.1080p.mkv", 33, 333, 3333),
+       TorrentFile("S01E02.720p.mkv", "444444444", "magnet:S01E02.720p.mkv", "url://S01E02.720p.mkv", 44, 444, 4444),
+       TorrentFile("S01E02.1080p.mkv", "555555555", "magnet:S01E02.1080p.mkv", "url://S01E02.1080p.mkv", 55, 555, 5555),
+       TorrentFile("S02E01.720p.mkv", "666666666", "magnet:S02E01.720p.mkv", "url://S02E01.720p.mkv", 66, 666, 6666),
+)
+
+private val episodes = listOf(
+       Episode(1, 1).apply {
+               addTorrentFile(torrentFiles[0])
+               addTorrentFile(torrentFiles[1])
+               addTorrentFile(torrentFiles[2])
+       },
+       Episode(1, 2).apply {
+               addTorrentFile(torrentFiles[3])
+               addTorrentFile(torrentFiles[4])
+       },
+       Episode(2, 1).apply {
+               addTorrentFile(torrentFiles[5])
+       }
+)
diff --git a/src/test/kotlin/net/pterodactylus/util/collection/Lists.kt b/src/test/kotlin/net/pterodactylus/util/collection/Lists.kt
new file mode 100644 (file)
index 0000000..e7dc1c8
--- /dev/null
@@ -0,0 +1,18 @@
+package net.pterodactylus.util.collection
+
+/**
+ * Batches the elements of this list into lists with the given size. All
+ * returned lists, except for the last list, will return the given number
+ * of elements.
+ *
+ * @param[batchSize] The size of the batches
+ * @param[T] The type of the list elements
+ * @return The batched lists
+ */
+fun <T> List<T>.batch(batchSize: Int): List<List<T>> = fold(mutableListOf<MutableList<T>>()) { prev, current ->
+       if (prev.isEmpty() || prev.last().size == batchSize) {
+               prev.add(mutableListOf())
+       }
+       prev.last() += current
+       prev
+}
diff --git a/src/test/kotlin/net/pterodactylus/util/dom/NodeLists.kt b/src/test/kotlin/net/pterodactylus/util/dom/NodeLists.kt
new file mode 100644 (file)
index 0000000..8d21535
--- /dev/null
@@ -0,0 +1,9 @@
+package net.pterodactylus.util.dom
+
+import org.w3c.dom.Node
+import org.w3c.dom.NodeList
+
+/**
+ * Converts an [org.w3c.dom.NodeList] to a [list][List] of [nodes][Node].
+ */
+fun NodeList.toList(): List<Node> = (0 until this.length).map { item(it) }
diff --git a/src/test/kotlin/net/pterodactylus/util/dom/XPathEvaluator.kt b/src/test/kotlin/net/pterodactylus/util/dom/XPathEvaluator.kt
new file mode 100644 (file)
index 0000000..fad459f
--- /dev/null
@@ -0,0 +1,20 @@
+package net.pterodactylus.util.dom
+
+import javax.xml.xpath.XPath
+import javax.xml.xpath.XPathConstants
+import javax.xml.xpath.XPathFactory
+import org.w3c.dom.Document
+import org.w3c.dom.Node
+import org.w3c.dom.NodeList
+
+class XPathEvaluator(private val document: Document) {
+
+       fun asNode(expression: String): Node = xPath.evaluate(expression, document, XPathConstants.NODE) as Node
+       fun asNodeList(expression: String): List<Node> = (xPath.evaluate(expression, document, XPathConstants.NODESET) as NodeList).toList()
+       fun asString(expression: String): String = xPath.evaluate(expression, document, XPathConstants.STRING) as String
+       fun asNumber(expression: String): Number = xPath.evaluate(expression, document, XPathConstants.NUMBER) as Number
+       fun asBoolean(expression: String): Boolean = xPath.evaluate(expression, document, XPathConstants.BOOLEAN) as Boolean
+
+       private val xPath: XPath = XPathFactory.newInstance().newXPath()
+
+}
diff --git a/src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt b/src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt
new file mode 100644 (file)
index 0000000..58f1b5b
--- /dev/null
@@ -0,0 +1,62 @@
+package net.pterodactylus.util.test
+
+import org.hamcrest.Description
+import org.hamcrest.TypeSafeDiagnosingMatcher
+import org.w3c.dom.Node
+
+/**
+ * Returns a Hamcrest [matcher][org.hamcrest.Matcher] that matches
+ * [nodes][Node] with a name of “a”, a “href” attribute matching the given
+ * [link], and a text matching the given [text].
+ *
+ * @param[text] The text of the link node
+ * @param[link] The link of the link node
+ * @return A [matcher][org.hamcrest.Matcher] for [nodes][Node]
+ */
+fun isLinkNode(text: String, link: String) = object : TypeSafeDiagnosingMatcher<Node>() {
+       override fun matchesSafely(node: Node, mismatchDescription: Description) =
+               if (node.nodeType != Node.ELEMENT_NODE) {
+                       mismatchDescription.appendText("is not link node")
+                       false
+               } else if (node.textContent != text) {
+                       mismatchDescription.appendText("text is ").appendValue(node.textContent)
+                       false
+               } else if (node.attributes.getNamedItem("href").nodeValue != link) {
+                       mismatchDescription.appendText("link is ").appendValue(node.attributes.getNamedItem("href").nodeValue)
+                       false
+               } else {
+                       true
+               }
+
+       override fun describeTo(description: Description) {
+               description.appendText("link node with text ").appendValue(text).appendText(" and link ").appendValue(link)
+       }
+}
+
+/**
+ * Returns a Hamcrest [matcher][org.hamcrest.Matcher] for [nodes][Node] that
+ * either are text nodes with the given [text], or that are element nodes whose
+ * [textContent][Node.getTextContent] is the given [text].
+ *
+ * @param[text] The text to match
+ * @return A [matcher][org.hamcrest.Matcher] for [nodes][Node]
+ */
+fun isTextNode(text: String) = object : TypeSafeDiagnosingMatcher<Node>() {
+       override fun matchesSafely(node: Node, mismatchDescription: Description) =
+               if ((node.nodeType != Node.TEXT_NODE) && (node.nodeType != Node.ELEMENT_NODE)) {
+                       mismatchDescription.appendText("is ").appendValue(node.nodeType).appendText(" node")
+                       false;
+               } else if ((node.nodeType == Node.TEXT_NODE) && (node.nodeValue != text)) {
+                       mismatchDescription.appendText("text is ").appendValue(node.nodeValue)
+                       false
+               } else if (node.textContent != text) {
+                       mismatchDescription.appendText("text content is ").appendValue(node.textContent)
+                       false
+               } else {
+                       true
+               }
+
+       override fun describeTo(description: Description) {
+               description.appendText("text node with text ").appendValue(text)
+       }
+}