From 5907dc859f5e666faa2c7b042ce38ce56003b439 Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20=E2=80=98Bombe=E2=80=99=20Roden?= Date: Tue, 7 Oct 2025 11:21:22 +0200 Subject: [PATCH] =?utf8?q?=E2=9C=85=20Add=20test=20for=20EpisodeState?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- .../rhynodge/states/EpisodeState.java | 2 +- .../rhynodge/states/EpisodeStateTest.kt | 153 +++++++++++++++++++++ .../net/pterodactylus/util/collection/Lists.kt | 18 +++ .../kotlin/net/pterodactylus/util/dom/NodeLists.kt | 9 ++ .../net/pterodactylus/util/dom/XPathEvaluator.kt | 20 +++ .../net/pterodactylus/util/test/NodeMatchers.kt | 62 +++++++++ 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/net/pterodactylus/rhynodge/states/EpisodeStateTest.kt create mode 100644 src/test/kotlin/net/pterodactylus/util/collection/Lists.kt create mode 100644 src/test/kotlin/net/pterodactylus/util/dom/NodeLists.kt create mode 100644 src/test/kotlin/net/pterodactylus/util/dom/XPathEvaluator.kt create mode 100644 src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt diff --git a/src/main/java/net/pterodactylus/rhynodge/states/EpisodeState.java b/src/main/java/net/pterodactylus/rhynodge/states/EpisodeState.java index d58870a..4520c8c 100644 --- a/src/main/java/net/pterodactylus/rhynodge/states/EpisodeState.java +++ b/src/main/java/net/pterodactylus/rhynodge/states/EpisodeState.java @@ -206,7 +206,7 @@ public class EpisodeState extends AbstractState implements Iterable { } else { htmlBuilder.append(""); } - htmlBuilder.append("").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append(""); + htmlBuilder.append("").append(StringEscapeUtils.escapeHtml4(torrentFile.name())).append(""); htmlBuilder.append("").append(StringEscapeUtils.escapeHtml4(torrentFile.size())).append(""); htmlBuilder.append("").append(torrentFile.fileCount()).append(""); htmlBuilder.append("").append(torrentFile.seedCount()).append(""); 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 index 0000000..a30f0ca --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/rhynodge/states/EpisodeStateTest.kt @@ -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 index 0000000..e7dc1c8 --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/util/collection/Lists.kt @@ -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 List.batch(batchSize: Int): List> = fold(mutableListOf>()) { 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 index 0000000..8d21535 --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/util/dom/NodeLists.kt @@ -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 = (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 index 0000000..fad459f --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/util/dom/XPathEvaluator.kt @@ -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 = (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 index 0000000..58f1b5b --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/util/test/NodeMatchers.kt @@ -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() { + 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() { + 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) + } +} -- 2.7.4